Skip to content

Commit d933f1e

Browse files
committed
Make onSkip required, delete the raw-stderr default entirely
Three review rounds each turned up one more expandPluginPath call site writing raw stderr mid-frame: the default onSkip was an optional parameter with a silent, harmful fallback, so every call site was a fresh chance to get it wrong and nothing caught it. Delete defaultExpandSkip and make onSkip a required field on ExpandPluginPathOptions. This turns every call site into a compile error until it picks a handler on purpose, which is how this round found the fourth instance: expandExistingPluginMembers called expandPluginPath with no onSkip, reachable from runner.ts's startup trust-migration path seven lines before the discovery call already fixed, and from exec/runner.ts where raw stderr is legitimate. expandExistingPluginMembers now takes a required onSkip too, so a skipped member's reason still reaches diagnostics even though the member itself is correctly dropped from the returned list (trust decisions must not pre-grant a directory that could appear later). Interactive callers pass expandSkipDiagnosticsHandler; exec passes an explicit stderr writer built from the newly exported formatExpandSkip, making that choice visible at its own call site instead of an invisible library default. expandSkipDiagnosticsHandler is now the ordinary handler (its diagnostics parameter is required, no more undefined fallback), and every internal call site that used to spread a conditional { onSkip } object now always passes one. Updated two pre-existing tests that asserted the old stderr-default behavior to instead assert onSkip is required and receives every skip, and added coverage for a skip reached through expandExistingPluginMembers.
1 parent 702ccf9 commit d933f1e

6 files changed

Lines changed: 126 additions & 48 deletions

File tree

src/exec/runner.ts

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,11 @@ import type {
5454
} from "../permission/types.js";
5555
import { createAgentToolset, type AgentToolset, type OperatorResult } from "../agent/tools.js";
5656
import { collectToolPlugins, resolveToolPlugins } from "../plugins/tool-plugins.js";
57-
import { expandExistingPluginMembers } from "../plugins/loader.js";
57+
import {
58+
expandExistingPluginMembers,
59+
formatExpandSkip,
60+
type ExpandPluginPathSkip,
61+
} from "../plugins/loader.js";
5862
import { isPluginTrusted, loadProjectTrust } from "../trust/project-trust.js";
5963
import {
6064
isPathPluginTrusted,
@@ -233,9 +237,15 @@ export async function runExec(config: Config): Promise<ExecResult> {
233237
let projectTrust = await loadProjectTrust(config.cwd);
234238
const isProjectPluginTrusted = (pluginPath: string) => isPluginTrusted(projectTrust, pluginPath);
235239
// One-shot migration only when the path-trust file does not exist yet.
240+
// Headless exec has no frame to corrupt, so a skipped marketplace member
241+
// writes straight to stderr here — an explicit choice at this call site,
242+
// not `expandPluginPath` falling back to it on its own.
236243
let pathTrust = await migratePathTrustFromPluginPaths(
237244
config.settings?.pluginPaths ?? [],
238-
(p) => expandExistingPluginMembers(p, config.cwd),
245+
(p) =>
246+
expandExistingPluginMembers(p, config.cwd, (skip: ExpandPluginPathSkip) => {
247+
process.stderr.write(`plugins: ${formatExpandSkip(skip)}\n`);
248+
}),
239249
undefined,
240250
{ onMigrated: reportPathTrustMigration },
241251
);

src/plugins/loader.ts

Lines changed: 48 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -255,39 +255,55 @@ export type ExpandPluginPathOptions = {
255255
* tree is allowed — multi-level relatives ok).
256256
*/
257257
containRoot?: string;
258-
/** Called for each skipped marketplace source (never silent). */
259-
onSkip?: (skip: ExpandPluginPathSkip) => void;
258+
/**
259+
* Called for each skipped marketplace source (never silent). Required —
260+
* not optional with a stderr default — because an optional sink with a
261+
* silent fallback is exactly the shape that let three review rounds each
262+
* turn up one more call site writing raw stderr mid-frame in the
263+
* interactive TUI (CL-5411). Making it required turns every call site
264+
* into a compile error until it picks a handler on purpose:
265+
* `expandSkipDiagnosticsHandler(diagnostics)` for a batching caller,
266+
* an explicit stderr writer for a headless caller where that is correct
267+
* and visible (see `src/exec/runner.ts`), or `() => {}` to state on the
268+
* record that a caller is deliberately ignoring skips.
269+
*/
270+
onSkip: (skip: ExpandPluginPathSkip) => void;
260271
};
261272

262-
/** Default skip reporter: stderr, same shape as Claude discovery. */
263-
function defaultExpandSkip(skip: ExpandPluginPathSkip): void {
264-
const where = skip.resolved !== undefined ? ` → ${skip.resolved}` : "";
265-
process.stderr.write(
266-
`plugins: skipped marketplace source ${JSON.stringify(skip.source)} (${skip.reason})${where}\n`,
267-
);
268-
}
269-
270-
function formatExpandSkip(skip: ExpandPluginPathSkip): string {
273+
/** One-line description of a skip, shared by every `onSkip` sink (diagnostics or stderr). */
274+
export function formatExpandSkip(skip: ExpandPluginPathSkip): string {
271275
const where = skip.resolved !== undefined ? ` → ${skip.resolved}` : "";
272276
return `marketplace source ${JSON.stringify(skip.source)} skipped (${skip.reason})${where}`;
273277
}
274278

275279
/**
276-
* Build an `onSkip` handler that collects into `diagnostics` instead of
277-
* `expandPluginPath`'s raw-stderr default. Every discovery path that already
278-
* threads a diagnostics collector through must pass this, or a skipped
279-
* marketplace member bypasses the collector entirely — not merely misrouted,
280-
* dropped, since nothing else observes `defaultExpandSkip`'s stderr write.
281-
* Returns undefined (falls back to the default) when no collector is given,
282-
* e.g. a direct caller with no batching in play.
280+
* The ordinary `onSkip` handler: collect into `diagnostics` instead of
281+
* writing raw stderr, so a skipped marketplace member lands in the same
282+
* end-of-batch summary as every other plugin-load warning.
283283
*/
284284
export function expandSkipDiagnosticsHandler(
285-
diagnostics?: PluginLoadDiagnostics,
286-
): ((skip: ExpandPluginPathSkip) => void) | undefined {
287-
if (diagnostics === undefined) return undefined;
285+
diagnostics: PluginLoadDiagnostics,
286+
): (skip: ExpandPluginPathSkip) => void {
288287
return (skip) => diagnostics.warnings.push(formatExpandSkip(skip));
289288
}
290289

290+
/**
291+
* `onSkip` when no diagnostics collector is in play: one explicit stderr
292+
* line, module-private and only reached by an internal caller's own
293+
* deliberate choice (see `resolveExpandSkip` below) — never `expandPluginPath`
294+
* falling back to it on its own.
295+
*/
296+
function stderrExpandSkip(skip: ExpandPluginPathSkip): void {
297+
process.stderr.write(`plugins: ${formatExpandSkip(skip)}\n`);
298+
}
299+
300+
/** Diagnostics when given, else the explicit stderr line — no silent option. */
301+
function resolveExpandSkip(
302+
diagnostics?: PluginLoadDiagnostics,
303+
): (skip: ExpandPluginPathSkip) => void {
304+
return diagnostics !== undefined ? expandSkipDiagnosticsHandler(diagnostics) : stderrExpandSkip;
305+
}
306+
291307
/**
292308
* Containment check with symlink safety. Lexical reject first; when both the
293309
* candidate and the contain root exist, realpath both and re-check so a symlink
@@ -334,11 +350,11 @@ function defaultContainRoot(marketplaceRoot: string): string {
334350

335351
export async function expandPluginPath(
336352
path: string,
337-
opts: ExpandPluginPathOptions = {},
353+
opts: ExpandPluginPathOptions,
338354
): Promise<string[]> {
339355
const marketplaceRoot = resolve(path);
340356
const report = (skip: ExpandPluginPathSkip): void => {
341-
(opts.onSkip ?? defaultExpandSkip)(skip);
357+
opts.onSkip(skip);
342358
};
343359

344360
// 1. Declared marketplace: relative `source` list, contained under containRoot
@@ -445,13 +461,17 @@ export async function expandPluginPath(
445461
// Resolve a registered pluginPaths entry to the member plugin directories that
446462
// exist on disk: relative entries resolve against cwd, marketplace roots expand
447463
// to their members. Missing paths are dropped so trust decisions made from this
448-
// list never pre-grant a directory that could appear later with other content.
464+
// list never pre-grant a directory that could appear later with other content
465+
// — that drop is deliberate, but a *skipped* member (bad source, escape) still
466+
// has a reason worth reaching the caller's diagnostics, so `onSkip` is
467+
// required rather than silently defaulted (see `ExpandPluginPathOptions`).
449468
export async function expandExistingPluginMembers(
450469
registeredPath: string,
451470
cwd: string,
471+
onSkip: (skip: ExpandPluginPathSkip) => void,
452472
): Promise<string[]> {
453473
const abs = isAbsolute(registeredPath) ? registeredPath : resolve(cwd, registeredPath);
454-
const members = await expandPluginPath(abs);
474+
const members = await expandPluginPath(abs, { onSkip });
455475
const existing = await Promise.all(members.map((m) => pathExists(m)));
456476
return members.filter((_, i) => existing[i]);
457477
}
@@ -480,8 +500,7 @@ async function scanPluginsDir(
480500
// Each entry may itself be a marketplace, so expand before loading. A
481501
// skipped member routes into `diagnostics` when the caller has one, same
482502
// as loadPluginEntry below — otherwise it would bypass the collector.
483-
const onSkip = expandSkipDiagnosticsHandler(diagnostics);
484-
const dirs = await expandPluginPath(join(dir, entry), onSkip !== undefined ? { onSkip } : {});
503+
const dirs = await expandPluginPath(join(dir, entry), { onSkip: resolveExpandSkip(diagnostics) });
485504
for (const d of dirs) {
486505
const abs = resolve(d);
487506
if (originRequiresTrust(origin) && isTrusted !== undefined && !isTrusted(abs)) {
@@ -562,11 +581,11 @@ export async function loadPluginsFromPaths(
562581
): Promise<PluginModule[]> {
563582
// A skipped member routes into `diagnostics` when the caller has one, same
564583
// reasoning as scanPluginsDir — otherwise it bypasses the collector.
565-
const onSkip = expandSkipDiagnosticsHandler(opts.diagnostics);
584+
const onSkip = resolveExpandSkip(opts.diagnostics);
566585
const resolved = await Promise.all(
567586
paths.map(async (p) => {
568587
const abs = isAbsolute(p) ? p : join(cwd, p);
569-
return expandPluginPath(abs, onSkip !== undefined ? { onSkip } : {});
588+
return expandPluginPath(abs, { onSkip });
570589
}),
571590
);
572591
// Anything under <cwd>/.corbits/plugins/ is project origin no matter how it

src/tui/plugin-diagnostics-sink.test.ts

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,9 @@ import {
99
} from "../plugins/diagnostics.js";
1010
import {
1111
discoverUserPlugins,
12+
expandExistingPluginMembers,
1213
expandPluginPath,
14+
expandSkipDiagnosticsHandler,
1315
loadPluginEntry,
1416
type ExpandPluginPathSkip,
1517
} from "../plugins/loader.js";
@@ -167,6 +169,39 @@ describe("interactive plugin diagnostics never hit raw stderr", () => {
167169
expect(writes).toBe(0);
168170
});
169171

172+
test("expandExistingPluginMembers: a skipped source is dropped from the result but reaches diagnostics", async () => {
173+
// This is the fourth site the same bug turned up in: it wraps
174+
// expandPluginPath for a registered `pluginPaths` entry (runner.ts's
175+
// startup migration/trust-seed call), so its `onSkip` is required too —
176+
// there is no default to silently fall through to anymore. Dropping the
177+
// member from the returned list is correct (trust decisions must not
178+
// pre-grant a directory that could appear later), but the skip reason
179+
// must still surface somewhere, not vanish.
180+
const root = await mkdtemp(join(tmpdir(), "diag-existing-members-"));
181+
const marketDir = join(root, "market");
182+
await mkdir(join(marketDir, ".claude-plugin"), { recursive: true });
183+
await writeFile(
184+
join(marketDir, ".claude-plugin", "marketplace.json"),
185+
JSON.stringify({
186+
name: "demo",
187+
plugins: [{ name: "bad", source: "/etc/not-a-plugin" }],
188+
}),
189+
);
190+
const { writes } = await withStderrCapture(async () => {
191+
const diag = createPluginLoadDiagnostics();
192+
const members = await expandExistingPluginMembers(
193+
marketDir,
194+
root,
195+
expandSkipDiagnosticsHandler(diag),
196+
);
197+
expect(members).toEqual([]);
198+
const message = formatPluginWarningsSummary(diag.warnings);
199+
expect(message).toBeDefined();
200+
expect(diag.warnings.some((w) => w.includes("/etc/not-a-plugin"))).toBe(true);
201+
});
202+
expect(writes).toBe(0);
203+
});
204+
170205
test("startup agent-profile resolution: a malformed profile stays silent on stderr", async () => {
171206
// Same call shape as runner.ts's startup `resolveAgentPluginProfiles`
172207
// (over `executablePlugins()` and the full `settings.plugins` config),

src/tui/runner.ts

Lines changed: 10 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -393,17 +393,21 @@ export async function runTUI(initialConfig: Config): Promise<number> {
393393
// global path-trust entry, load metadata-only (no import).
394394
// Claude Code marketplace installs are opt-in via settings.discoverClaudePlugins.
395395
let projectTrust: ProjectTrustStore = await loadProjectTrust(config.cwd);
396+
// Declared before the migration call below so a skipped marketplace member
397+
// (bad pluginPaths entry) collects into the same summary as discovery,
398+
// rather than defaulting to stderr — `onSkip` on expandExistingPluginMembers
399+
// is required precisely so this can't be forgotten at a call site.
400+
const pluginLoadDiag = createPluginLoadDiagnostics();
396401
// One-shot: seed global path trust from pluginPaths only when the store file
397402
// does not exist yet (legacy per-cwd grants). Later boots load the store as-is.
398403
let pathTrust: PathTrustStore = await migratePathTrustFromPluginPaths(
399404
config.settings?.pluginPaths ?? [],
400-
(p) => expandExistingPluginMembers(p, config.cwd),
405+
(p) => expandExistingPluginMembers(p, config.cwd, expandSkipDiagnosticsHandler(pluginLoadDiag)),
401406
undefined,
402407
{ onMigrated: reportPathTrustMigration },
403408
);
404409
const isProjectPluginTrusted = (pluginPath: string) => isPluginTrusted(projectTrust, pluginPath);
405410
const isRegisteredPathTrusted = (pluginPath: string) => isPathPluginTrusted(pathTrust, pluginPath);
406-
const pluginLoadDiag = createPluginLoadDiagnostics();
407411
const pluginModules = await discoverSessionPlugins({
408412
cwd: config.cwd,
409413
...(config.settings?.pluginPaths !== undefined
@@ -897,13 +901,11 @@ export async function runTUI(initialConfig: Config): Promise<number> {
897901
// Persist global path trust only once it resolves to a real plugin, so a
898902
// bogus path never leaves a dangling entry. Expand marketplaces so each
899903
// member is trusted (exact-path match on reload). `onSkip` collects into
900-
// `addDiag` instead of the default stderr write — same reasoning as
904+
// `addDiag` instead of a raw stderr write — same reasoning as
901905
// `loadPluginEntry` above.
902-
const addSkipHandler = expandSkipDiagnosticsHandler(addDiag);
903-
const members = await expandPluginPath(
904-
abs,
905-
addSkipHandler !== undefined ? { onSkip: addSkipHandler } : {},
906-
);
906+
const members = await expandPluginPath(abs, {
907+
onSkip: expandSkipDiagnosticsHandler(addDiag),
908+
});
907909
pathTrust = await trustPathPlugins(members.length > 0 ? members : [abs]);
908910
// Replace any existing descriptor/candidate with the same id so re-adding
909911
// refreshes rather than duplicates.

tests/unit/path-plugin-trust.test.ts

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import {
77
discoverUserPlugins,
88
expandPluginPath,
99
loadPluginsFromPaths,
10+
type ExpandPluginPathSkip,
1011
} from "../../src/plugins/loader.js";
1112
import {
1213
isPathPluginTrusted,
@@ -17,6 +18,13 @@ import {
1718
} from "../../src/trust/path-trust.js";
1819
import { isPluginTrusted, loadProjectTrust, trustPlugin } from "../../src/trust/project-trust.js";
1920

21+
// `onSkip` is required on `expandPluginPath` — no default sink to fall back
22+
// to. These fixtures expect every declared member to resolve, so a skip here
23+
// is a test-fixture bug; fail loudly instead of silently passing it through.
24+
function failOnSkip(skip: ExpandPluginPathSkip): never {
25+
throw new Error(`unexpected marketplace skip: ${JSON.stringify(skip)}`);
26+
}
27+
2028
async function writeCommandPlugin(dir: string, id: string, marker?: string): Promise<void> {
2129
await mkdir(dir, { recursive: true });
2230
await writeFile(
@@ -202,7 +210,7 @@ describe("path plugin trust across working directories", () => {
202210
"utf8",
203211
);
204212

205-
const members = await expandPluginPath(root);
213+
const members = await expandPluginPath(root, { onSkip: failOnSkip });
206214
expect(members).toEqual([alpha, beta]);
207215
await trustPathPlugins(members, home);
208216
const pathTrust = await loadPathTrust(home);
@@ -236,7 +244,7 @@ describe("path plugin trust across working directories", () => {
236244
"utf8",
237245
);
238246

239-
const members = await expandPluginPath(root);
247+
const members = await expandPluginPath(root, { onSkip: failOnSkip });
240248
expect(members).toEqual([sibling]);
241249
await trustPathPlugins(members, home);
242250
const pathTrust = await loadPathTrust(home);

tests/unit/plugin-marketplace.test.ts

Lines changed: 11 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -178,7 +178,12 @@ test("path expand reports skips via onSkip (never silent when callback set)", as
178178
}
179179
});
180180

181-
test("default expand path reports skips to stderr (non-silent without onSkip)", async () => {
181+
test("onSkip is required — every skip reaches the caller's handler, none silent", async () => {
182+
// `expandPluginPath` has no default sink: `onSkip` is a required field on
183+
// its options (CL-5411 round 4) so a caller cannot forget it and fall
184+
// through to a raw stderr write. This drives the real function with an
185+
// explicit collecting handler and confirms every skip reaches it — stderr
186+
// stays untouched, since there is no implicit fallback left to reach it.
182187
const base = await mkdtemp(join(tmpdir(), "corbits-mkt-stderr-"));
183188
try {
184189
const root = join(base, "marketplace");
@@ -198,14 +203,13 @@ test("default expand path reports skips to stderr (non-silent without onSkip)",
198203
writes.push(typeof chunk === "string" ? chunk : Buffer.from(chunk).toString("utf8"));
199204
return true;
200205
}) as typeof process.stderr.write;
206+
const skips: ExpandPluginPathSkip[] = [];
201207
try {
202-
const members = await expandPluginPath(root);
208+
const members = await expandPluginPath(root, { onSkip: (s) => skips.push(s) });
203209
expect(members).toEqual([]);
204-
expect(writes.some((w) => w.includes("skipped marketplace source"))).toBe(true);
205-
expect(writes.some((w) => w.includes("absolute"))).toBe(true);
206-
expect(writes.some((w) => w.includes("missing"))).toBe(true);
207-
// Default reporter uses the original relative source string for missing.
208-
expect(writes.some((w) => w.includes("./plugins/missing"))).toBe(true);
210+
expect(writes).toEqual([]);
211+
expect(skips.some((s) => s.reason === "absolute" && s.source === "/tmp/not-a-plugin")).toBe(true);
212+
expect(skips.some((s) => s.reason === "missing" && s.source === "./plugins/missing")).toBe(true);
209213
} finally {
210214
process.stderr.write = origWrite;
211215
}

0 commit comments

Comments
 (0)