Skip to content

Commit 702ccf9

Browse files
committed
Collect discovery-time marketplace skips, surface profile warnings
Two more gaps in the plugin-diagnostics fix: scanPluginsDir and loadPluginsFromPaths both call expandPluginPath with no onSkip, so a marketplace member skipped during discovery (a bad source under .corbits/plugins/ or a registered pluginPaths entry) fell through to the raw-stderr default. Unlike the earlier sites, this one bypassed the diagnostics collector entirely rather than just misrouting to the log, so the warning was lost outright. Added expandSkipDiagnosticsHandler in loader.ts and wired it into both call sites, plus addPath in runner.ts (replacing its duplicate inline version). profileDiag, the startup agent-profile resolution, was still logged only and never reached startupPluginNotices — one of the sites the previous commit's message claimed to have fixed but did not. Gave it the same treatment as its discovery and tool-plugin siblings. Extended the behavioral test with the marketplace-discovery-skip case (asserting the warning lands in diagnostics, not just that stderr stays quiet) and the startup agent-profile path.
1 parent 38ce496 commit 702ccf9

3 files changed

Lines changed: 98 additions & 13 deletions

File tree

src/plugins/loader.ts

Lines changed: 30 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -267,6 +267,27 @@ function defaultExpandSkip(skip: ExpandPluginPathSkip): void {
267267
);
268268
}
269269

270+
function formatExpandSkip(skip: ExpandPluginPathSkip): string {
271+
const where = skip.resolved !== undefined ? ` → ${skip.resolved}` : "";
272+
return `marketplace source ${JSON.stringify(skip.source)} skipped (${skip.reason})${where}`;
273+
}
274+
275+
/**
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.
283+
*/
284+
export function expandSkipDiagnosticsHandler(
285+
diagnostics?: PluginLoadDiagnostics,
286+
): ((skip: ExpandPluginPathSkip) => void) | undefined {
287+
if (diagnostics === undefined) return undefined;
288+
return (skip) => diagnostics.warnings.push(formatExpandSkip(skip));
289+
}
290+
270291
/**
271292
* Containment check with symlink safety. Lexical reject first; when both the
272293
* candidate and the contain root exist, realpath both and re-check so a symlink
@@ -456,8 +477,11 @@ async function scanPluginsDir(
456477

457478
const results: PluginModule[] = [];
458479
for (const entry of entries) {
459-
// Each entry may itself be a marketplace, so expand before loading.
460-
const dirs = await expandPluginPath(join(dir, entry));
480+
// Each entry may itself be a marketplace, so expand before loading. A
481+
// skipped member routes into `diagnostics` when the caller has one, same
482+
// 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 } : {});
461485
for (const d of dirs) {
462486
const abs = resolve(d);
463487
if (originRequiresTrust(origin) && isTrusted !== undefined && !isTrusted(abs)) {
@@ -536,10 +560,13 @@ export async function loadPluginsFromPaths(
536560
diagnostics?: PluginLoadDiagnostics;
537561
} = {},
538562
): Promise<PluginModule[]> {
563+
// A skipped member routes into `diagnostics` when the caller has one, same
564+
// reasoning as scanPluginsDir — otherwise it bypasses the collector.
565+
const onSkip = expandSkipDiagnosticsHandler(opts.diagnostics);
539566
const resolved = await Promise.all(
540567
paths.map(async (p) => {
541568
const abs = isAbsolute(p) ? p : join(cwd, p);
542-
return expandPluginPath(abs);
569+
return expandPluginPath(abs, onSkip !== undefined ? { onSkip } : {});
543570
}),
544571
);
545572
// Anything under <cwd>/.corbits/plugins/ is project origin no matter how it

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

Lines changed: 58 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,12 @@ import {
77
emitPluginWarningLog,
88
formatPluginWarningsSummary,
99
} from "../plugins/diagnostics.js";
10-
import { expandPluginPath, loadPluginEntry, type ExpandPluginPathSkip } from "../plugins/loader.js";
10+
import {
11+
discoverUserPlugins,
12+
expandPluginPath,
13+
loadPluginEntry,
14+
type ExpandPluginPathSkip,
15+
} from "../plugins/loader.js";
1116
import { resolveAgentPluginProfiles } from "../plugins/agent-plugins.js";
1217
import { resolveToolPlugins, type ToolPluginCandidate } from "../plugins/tool-plugins.js";
1318
import { discoverSessionPlugins } from "../session/runtime-assembly.js";
@@ -132,6 +137,58 @@ describe("interactive plugin diagnostics never hit raw stderr", () => {
132137
expect(writes).toBe(0);
133138
});
134139

140+
test("startup discovery: a marketplace member skipped during discovery is collected, not lost", async () => {
141+
// Reproduces the exact shape reported against `scanPluginsDir` /
142+
// `loadPluginsFromPaths`: a project-local `.corbits/plugins/` entry that
143+
// is itself a marketplace with one bad (absolute) `source`. Both call
144+
// `expandPluginPath` internally; without `onSkip` wired to `diagnostics`,
145+
// the skip bypasses the collector entirely (not merely misrouted to the
146+
// log — genuinely dropped, since nothing else observes the raw write).
147+
const cwd = await mkdtemp(join(tmpdir(), "diag-cwd-market-"));
148+
const marketDir = join(cwd, ".corbits", "plugins", "market");
149+
await mkdir(join(marketDir, ".claude-plugin"), { recursive: true });
150+
await writeFile(
151+
join(marketDir, ".claude-plugin", "marketplace.json"),
152+
JSON.stringify({
153+
name: "demo",
154+
plugins: [{ name: "bad", source: "/etc/not-a-plugin" }],
155+
}),
156+
);
157+
const { writes } = await withStderrCapture(async () => {
158+
const diag = createPluginLoadDiagnostics();
159+
await discoverUserPlugins(cwd, { diagnostics: diag });
160+
// The skip must land in the collector, not just avoid stderr — a write
161+
// that silently vanishes without reaching diagnostics is the same
162+
// lost-warning bug reached by a different route.
163+
const message = formatPluginWarningsSummary(diag.warnings);
164+
expect(message).toBeDefined();
165+
expect(diag.warnings.some((w) => w.includes("/etc/not-a-plugin"))).toBe(true);
166+
});
167+
expect(writes).toBe(0);
168+
});
169+
170+
test("startup agent-profile resolution: a malformed profile stays silent on stderr", async () => {
171+
// Same call shape as runner.ts's startup `resolveAgentPluginProfiles`
172+
// (over `executablePlugins()` and the full `settings.plugins` config),
173+
// distinct from the verify-time call above which targets one plugin id.
174+
const mod = {
175+
manifest: { id: "startup-agent", name: "Startup Agent", kind: "agent" as const },
176+
agentPlugin: { agents: [{ description: "missing the required id field" }] },
177+
};
178+
const { writes } = await withStderrCapture(async () => {
179+
const diag = createPluginLoadDiagnostics();
180+
const profiles = await resolveAgentPluginProfiles(
181+
[mod],
182+
{ "startup-agent": { enabled: true } },
183+
{ diagnostics: diag },
184+
);
185+
expect(profiles).toEqual([]);
186+
const message = formatPluginWarningsSummary(diag.warnings);
187+
expect(message).toBeDefined();
188+
});
189+
expect(writes).toBe(0);
190+
});
191+
135192
test("tool-resolve: a throwing tool-plugin factory stays silent on stderr", async () => {
136193
const candidate: ToolPluginCandidate = {
137194
id: "throws",

src/tui/runner.ts

Lines changed: 10 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -50,8 +50,8 @@ import { refreshCodexInstructions } from "../auth/codex/instructions.js";
5050
import {
5151
expandExistingPluginMembers,
5252
expandPluginPath,
53+
expandSkipDiagnosticsHandler,
5354
loadPluginEntry,
54-
type ExpandPluginPathSkip,
5555
type PluginOrigin,
5656
} from "../plugins/loader.js";
5757
import {
@@ -899,14 +899,11 @@ export async function runTUI(initialConfig: Config): Promise<number> {
899899
// member is trusted (exact-path match on reload). `onSkip` collects into
900900
// `addDiag` instead of the default stderr write — same reasoning as
901901
// `loadPluginEntry` above.
902-
const members = await expandPluginPath(abs, {
903-
onSkip: (skip: ExpandPluginPathSkip) => {
904-
const where = skip.resolved !== undefined ? ` → ${skip.resolved}` : "";
905-
addDiag.warnings.push(
906-
`marketplace source ${JSON.stringify(skip.source)} skipped (${skip.reason})${where}`,
907-
);
908-
},
909-
});
902+
const addSkipHandler = expandSkipDiagnosticsHandler(addDiag);
903+
const members = await expandPluginPath(
904+
abs,
905+
addSkipHandler !== undefined ? { onSkip: addSkipHandler } : {},
906+
);
910907
pathTrust = await trustPathPlugins(members.length > 0 ? members : [abs]);
911908
// Replace any existing descriptor/candidate with the same id so re-adding
912909
// refreshes rather than duplicates.
@@ -985,6 +982,10 @@ export async function runTUI(initialConfig: Config): Promise<number> {
985982
{ diagnostics: profileDiag },
986983
);
987984
emitPluginWarningLog(profileDiag);
985+
// Same fire-and-forget reasoning as the discovery/tool-plugin notices above:
986+
// this runs before `host` exists, so it is queued rather than dropped.
987+
const profileNotice = formatPluginWarningsSummary(profileDiag.warnings);
988+
if (profileNotice !== undefined) startupPluginNotices.push(profileNotice);
988989
const initialProfiles = await loadAgentProfiles(profilesDir, pluginAgentProfiles);
989990
let liveAgentProfiles = initialProfiles;
990991

0 commit comments

Comments
 (0)