Skip to content

Commit 38ce496

Browse files
committed
Catch the remaining raw-stderr sites, and stop silencing warnings
Two more plugin-diagnostics call sites reachable from the interactive TUI still hit a raw stderr write, corrupting the alt-screen frame the same way the first four did: resolveToolPlugins (a throwing tool- plugin factory) and expandPluginPath's default onSkip (a skipped marketplace member from add-by-path). Both now collect into a diagnostics object instead. The previous fix also introduced a regression: three of the four sites routed their warnings to the log file only, and nothing in the TUI reads that file, so a broken skill ref became invisible instead of loud. Apply the addPath pattern (already in this diff) to the other three: verify and the trust-grant path now fold warnings into their existing message/notify channel, and the fire-and-forget startup paths (discovery, tool-plugin resolution) queue a transcript row shown once the shell mounts instead of dropping silently. Replaced the source-grep regression test with a behavioral one that drives the five real code paths (discovery, trust-grant, verify, add-path, tool-resolve) with process.stderr.write instrumented, since a text scan of one function name in one file could never have caught either of the two sites above.
1 parent 3847c90 commit 38ce496

5 files changed

Lines changed: 234 additions & 24 deletions

File tree

src/plugins/admin.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,10 @@ export type PluginsAdmin = {
2626
list: () => PluginDescriptor[];
2727
getConfig: () => Record<string, PluginConfig>;
2828
getWebOverride: () => string | undefined;
29-
saveConfig: (id: string, cfg: PluginConfig) => Promise<void> | void;
29+
// A trust-grant load (enabling a metadata-only plugin) can surface skill-miss
30+
// and similar warnings; the optional message lets the caller show them
31+
// instead of dropping them on the floor.
32+
saveConfig: (id: string, cfg: PluginConfig) => Promise<{ message?: string } | void> | void;
3033
setWebOverride: (id: string | undefined) => Promise<void> | void;
3134
verify: (id: string, credentials: Record<string, string>) => Promise<VerifyResult>;
3235
// Register a plugin from an arbitrary file/dir path, persisting it so it loads

src/plugins/tool-plugins.ts

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,10 @@ import type { PluginModule } from "./loader.js";
33
import type { PluginConfig } from "../config/settings.js";
44
import type { PluginCredentialField } from "./manifest.js";
55
import { scrubSecrets } from "../web/secret-scrub.js";
6+
import {
7+
resolvePluginWarningHandler,
8+
type PluginLoadDiagnostics,
9+
} from "./diagnostics.js";
610

711
// A discovered plugin that contributes agent tools: a "tool"-kind manifest plus
812
// the factory the loader captured.
@@ -37,18 +41,28 @@ export function isToolPluginActive(config: Record<string, PluginConfig>, id: str
3741
}
3842

3943
// Instantiate every enabled+consented tool plugin. A factory that throws is
40-
// logged and skipped rather than aborting the run.
44+
// reported and skipped rather than aborting the run. Pass `diagnostics` from
45+
// an interactive caller (the TUI holds the alternate screen for the whole
46+
// session — a bare stderr write mid-frame corrupts it); without it this falls
47+
// back to one stderr line per failure, same as `resolvePluginWarningHandler`
48+
// elsewhere in the plugin loader.
4149
export async function resolveToolPlugins(args: {
4250
candidates: ToolPluginCandidate[];
4351
pluginConfig: Record<string, PluginConfig>;
52+
diagnostics?: PluginLoadDiagnostics;
4453
}): Promise<ToolPlugin[]> {
54+
const onWarning = resolvePluginWarningHandler(
55+
args.diagnostics === undefined ? {} : { diagnostics: args.diagnostics },
56+
);
4557
const out: ToolPlugin[] = [];
4658
for (const cand of args.candidates) {
4759
if (!isToolPluginActive(args.pluginConfig, cand.id)) continue;
4860
try {
4961
out.push(await cand.factory(args.pluginConfig[cand.id]?.credentials ?? {}));
5062
} catch (err) {
51-
process.stderr.write(`tool-plugin: failed to start "${cand.id}": ${scrubSecrets(err instanceof Error ? err.message : String(err))}\n`);
63+
onWarning(
64+
`tool-plugin: failed to start "${cand.id}": ${scrubSecrets(err instanceof Error ? err.message : String(err))}`,
65+
);
5266
}
5367
}
5468
return out;

src/tui-opentui/command-surfaces.ts

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -86,7 +86,12 @@ export type PermissionsSurfaceDeps = {
8686

8787
export type PluginsSurfaceDeps = {
8888
readonly list: () => readonly PluginEntry[]
89-
readonly setEnabled: (id: string, enabled: boolean) => Promise<void> | void
89+
// A trust-grant load can surface skill-miss and similar warnings; the
90+
// optional message is shown via `deps.notify` at the call site.
91+
readonly setEnabled: (
92+
id: string,
93+
enabled: boolean,
94+
) => Promise<{ message?: string } | void> | void
9095
/** Persists credential values for the plugin (does not enable/verify it). */
9196
readonly saveCredentials: (
9297
id: string,
@@ -814,7 +819,10 @@ export function openPluginsSurface(shell: AppShell, deps: CommandSurfaceDeps): v
814819
return
815820
}
816821
void Promise.resolve(plugins.setEnabled(target.id, !target.enabled)).then(
817-
() => openPluginsSurface(shell, deps),
822+
(result) => {
823+
if (result?.message !== undefined) deps.notify(result.message)
824+
openPluginsSurface(shell, deps)
825+
},
818826
(err: unknown) => deps.notify(`Plugin update failed: ${errorText(err)}`),
819827
)
820828
},
@@ -847,7 +855,10 @@ export function openPluginsSurface(shell: AppShell, deps: CommandSurfaceDeps): v
847855
case "t":
848856
if (target.needsTrust !== true) return false
849857
void Promise.resolve(plugins.setEnabled(target.id, true)).then(
850-
() => openPluginsSurface(shell, deps),
858+
(result) => {
859+
if (result?.message !== undefined) deps.notify(result.message)
860+
openPluginsSurface(shell, deps)
861+
},
851862
(err: unknown) => deps.notify(`Trust failed: ${errorText(err)}`),
852863
)
853864
return true
Lines changed: 151 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,157 @@
11
import { describe, expect, test } from "bun:test";
2-
import { readFile } from "node:fs/promises";
2+
import { mkdir, mkdtemp, writeFile } from "node:fs/promises";
3+
import { tmpdir } from "node:os";
34
import { join } from "node:path";
5+
import {
6+
createPluginLoadDiagnostics,
7+
emitPluginWarningLog,
8+
formatPluginWarningsSummary,
9+
} from "../plugins/diagnostics.js";
10+
import { expandPluginPath, loadPluginEntry, type ExpandPluginPathSkip } from "../plugins/loader.js";
11+
import { resolveAgentPluginProfiles } from "../plugins/agent-plugins.js";
12+
import { resolveToolPlugins, type ToolPluginCandidate } from "../plugins/tool-plugins.js";
13+
import { discoverSessionPlugins } from "../session/runtime-assembly.js";
414

515
// The TUI holds the alternate screen for the whole interactive session, so any
6-
// plugin-diagnostics summary that lands on raw stderr corrupts the rendered
7-
// frame instead of showing up as a single controlled line (see CL-5411).
8-
// `emitPluginWarningSummary` defaults to a raw `process.stderr.write` sink
9-
// when called with no second argument; interactive callers must route through
10-
// `emitPluginWarningLog` (the structured-logger sink) instead.
11-
describe("runner.ts plugin diagnostics", () => {
12-
test("never calls emitPluginWarningSummary with its raw-stderr default", async () => {
13-
const src = await readFile(join(import.meta.dir, "runner.ts"), "utf8");
14-
const bareCalls = src.match(/emitPluginWarningSummary\([^,)]+\)/g) ?? [];
15-
expect(bareCalls).toEqual([]);
16+
// of the real plugin-loading paths runner.ts drives at startup / enable /
17+
// verify / add-path / tool-resolve time must never write to raw stderr — a
18+
// bare write lands mid-frame and corrupts the rendered transcript (CL-5411).
19+
// This instruments process.stderr.write around each real code path runner.ts
20+
// calls (not a source grep for one function name), so it catches the bug
21+
// class regardless of which function or file the write comes from.
22+
23+
async function withStderrCapture<T>(fn: () => Promise<T>): Promise<{ result: T; writes: number }> {
24+
const original = process.stderr.write.bind(process.stderr);
25+
let writes = 0;
26+
process.stderr.write = ((..._args: unknown[]) => {
27+
writes++;
28+
return true;
29+
}) as typeof process.stderr.write;
30+
try {
31+
const result = await fn();
32+
return { result, writes };
33+
} finally {
34+
process.stderr.write = original;
35+
}
36+
}
37+
38+
async function makeAgentPluginWithMissingSkill(): Promise<string> {
39+
const dir = await mkdtemp(join(tmpdir(), "diag-behavior-"));
40+
const agentsDir = join(dir, "agents");
41+
await mkdir(agentsDir, { recursive: true });
42+
await writeFile(
43+
join(agentsDir, "a.md"),
44+
"---\nskills: [does-not-exist]\n---\nbody\n",
45+
);
46+
await writeFile(
47+
join(dir, "plugin.json"),
48+
JSON.stringify({ id: "diag-behavior", name: "diag-behavior", kind: "agent" }),
49+
);
50+
return dir;
51+
}
52+
53+
describe("interactive plugin diagnostics never hit raw stderr", () => {
54+
test("startup discovery: a plugin with a missing skill ref stays silent on stderr", async () => {
55+
const pluginDir = await makeAgentPluginWithMissingSkill();
56+
const cwd = await mkdtemp(join(tmpdir(), "diag-cwd-"));
57+
const { writes } = await withStderrCapture(async () => {
58+
const diag = createPluginLoadDiagnostics();
59+
await discoverSessionPlugins({
60+
cwd,
61+
pluginPaths: [pluginDir],
62+
isProjectPluginTrusted: () => true,
63+
isRegisteredPathTrusted: () => true,
64+
diagnostics: diag,
65+
});
66+
emitPluginWarningLog(diag);
67+
});
68+
expect(writes).toBe(0);
69+
});
70+
71+
test("trust-grant / enable: loading a plugin with a missing skill ref stays silent on stderr", async () => {
72+
const pluginDir = await makeAgentPluginWithMissingSkill();
73+
const { writes } = await withStderrCapture(async () => {
74+
const diag = createPluginLoadDiagnostics();
75+
await loadPluginEntry(pluginDir, { cwd: pluginDir, origin: "path", diagnostics: diag });
76+
// Same fold-into-message pattern the fix applies in runner.ts's
77+
// `saveConfig` — never a bare `emitPluginWarningSummary(diag)`.
78+
const message = formatPluginWarningsSummary(diag.warnings);
79+
expect(message).toBeDefined();
80+
});
81+
expect(writes).toBe(0);
82+
});
83+
84+
test("verify: an agent profile that fails schema validation stays silent on stderr", async () => {
85+
// resolveAgentPluginProfiles validates AgentProfileSchema (requires `id`);
86+
// build a module with a malformed profile directly rather than round-
87+
// tripping through markdown, since that's the exact shape runner.ts's
88+
// `verify` handler passes in from an already-loaded module.
89+
const mod = {
90+
manifest: { id: "malformed-agent", name: "Malformed Agent", kind: "agent" as const },
91+
agentPlugin: { agents: [{ description: "missing the required id field" }] },
92+
};
93+
const { writes } = await withStderrCapture(async () => {
94+
const diag = createPluginLoadDiagnostics();
95+
const profiles = await resolveAgentPluginProfiles(
96+
[mod],
97+
{ "malformed-agent": { enabled: true } },
98+
{ diagnostics: diag },
99+
);
100+
expect(profiles).toEqual([]);
101+
const message = formatPluginWarningsSummary(diag.warnings);
102+
expect(message).toBeDefined();
103+
});
104+
expect(writes).toBe(0);
105+
});
106+
107+
test("add-path: a marketplace with a skipped member (outside contain root) stays silent on stderr", async () => {
108+
const root = await mkdtemp(join(tmpdir(), "diag-market-"));
109+
const marketDir = join(root, "market");
110+
await mkdir(join(marketDir, ".claude-plugin"), { recursive: true });
111+
await writeFile(
112+
join(marketDir, ".claude-plugin", "marketplace.json"),
113+
JSON.stringify({
114+
name: "demo",
115+
plugins: [
116+
// Absolute source is always skipped — same shape as a real bad entry.
117+
{ name: "bad", source: "/etc/not-a-plugin" },
118+
],
119+
}),
120+
);
121+
const { writes } = await withStderrCapture(async () => {
122+
const diag = createPluginLoadDiagnostics();
123+
const members = await expandPluginPath(marketDir, {
124+
onSkip: (skip: ExpandPluginPathSkip) => {
125+
diag.warnings.push(`marketplace source ${JSON.stringify(skip.source)} skipped (${skip.reason})`);
126+
},
127+
});
128+
expect(members).toEqual([]);
129+
const message = formatPluginWarningsSummary(diag.warnings);
130+
expect(message).toBeDefined();
131+
});
132+
expect(writes).toBe(0);
133+
});
134+
135+
test("tool-resolve: a throwing tool-plugin factory stays silent on stderr", async () => {
136+
const candidate: ToolPluginCandidate = {
137+
id: "throws",
138+
name: "Throws",
139+
credentials: [],
140+
factory: () => {
141+
throw new Error("boom");
142+
},
143+
};
144+
const { writes } = await withStderrCapture(async () => {
145+
const diag = createPluginLoadDiagnostics();
146+
const tools = await resolveToolPlugins({
147+
candidates: [candidate],
148+
pluginConfig: { throws: { enabled: true, consented: true } },
149+
diagnostics: diag,
150+
});
151+
expect(tools).toEqual([]);
152+
const message = formatPluginWarningsSummary(diag.warnings);
153+
expect(message).toBeDefined();
154+
});
155+
expect(writes).toBe(0);
16156
});
17157
});

src/tui/runner.ts

Lines changed: 49 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,13 @@ import { createInferenceDependencies } from "../provider/inference-dependencies.
4747
import { getValidCodexToken } from "../auth/codex/session.js";
4848
import { getValidXaiToken } from "../auth/xai/session.js";
4949
import { refreshCodexInstructions } from "../auth/codex/instructions.js";
50-
import { expandExistingPluginMembers, expandPluginPath, loadPluginEntry, type PluginOrigin } from "../plugins/loader.js";
50+
import {
51+
expandExistingPluginMembers,
52+
expandPluginPath,
53+
loadPluginEntry,
54+
type ExpandPluginPathSkip,
55+
type PluginOrigin,
56+
} from "../plugins/loader.js";
5157
import {
5258
createPluginLoadDiagnostics,
5359
emitPluginWarningLog,
@@ -411,6 +417,14 @@ export async function runTUI(initialConfig: Config): Promise<number> {
411417
diagnostics: pluginLoadDiag,
412418
});
413419
emitPluginWarningLog(pluginLoadDiag);
420+
// Fire-and-forget startup diagnostics (this + tool-plugin resolution below)
421+
// have no result channel back to an operator action, unlike verify/add-path/
422+
// trust-grant. A log-only summary is invisible — nobody watches
423+
// ~/.corbits/logs/corbits.log — so these are also queued as transcript rows
424+
// once the shell mounts (see `systemRow` calls after `mountRunnerHost`).
425+
const startupPluginNotices: string[] = [];
426+
const discoveryNotice = formatPluginWarningsSummary(pluginLoadDiag.warnings);
427+
if (discoveryNotice !== undefined) startupPluginNotices.push(discoveryNotice);
414428
// Mutable list so trusting a project/path plugin can replace a metadata-only stub
415429
// with a fully loaded module without restarting the process.
416430
let livePluginModules = pluginModules;
@@ -667,6 +681,7 @@ export async function runTUI(initialConfig: Config): Promise<number> {
667681
// Tool plugins are wired in only when enabled AND consented.
668682
const toolPluginCandidates = collectToolPlugins(executablePlugins());
669683
// Web and tool plugin resolution are independent, so resolve them concurrently.
684+
const toolPluginDiag = createPluginLoadDiagnostics();
670685
const [activeWeb, extraToolPlugins] = await Promise.all([
671686
resolveWebProviderFromPlugins({
672687
candidates: webPluginCandidates,
@@ -676,9 +691,13 @@ export async function runTUI(initialConfig: Config): Promise<number> {
676691
resolveToolPlugins({
677692
candidates: toolPluginCandidates,
678693
pluginConfig: config.settings?.plugins ?? {},
694+
diagnostics: toolPluginDiag,
679695
}),
680696
]);
681697
if (activeWeb !== undefined) setActiveWebProviderBrand(webBrand(activeWeb.name));
698+
emitPluginWarningLog(toolPluginDiag);
699+
const toolPluginNotice = formatPluginWarningsSummary(toolPluginDiag.warnings);
700+
if (toolPluginNotice !== undefined) startupPluginNotices.push(toolPluginNotice);
682701

683702
// /plugins UI backend: discovered plugin descriptors plus live, persisted
684703
// config (enabled flag, credentials, web override, extra paths) written to the
@@ -745,6 +764,11 @@ export async function runTUI(initialConfig: Config): Promise<number> {
745764
getWebOverride: () => liveWebOverride,
746765
saveConfig: async (id, cfg) => {
747766
livePluginConfig = { ...livePluginConfig, [id]: cfg };
767+
// Warnings from the trust-grant load below are collected, not logged:
768+
// like `addPath`, the caller has a result channel back to the operator
769+
// (the command surface's `deps.notify`), so fold them into the returned
770+
// message instead of a log line nobody watches.
771+
let trustGrantMessage: string | undefined;
748772
// Enabling a project/path plugin records trust and full-loads code.
749773
if (cfg.enabled === true) {
750774
const stub = livePluginModules.find((m) => m.manifest?.id === id);
@@ -766,7 +790,7 @@ export async function runTUI(initialConfig: Config): Promise<number> {
766790
origin: stub.origin,
767791
diagnostics: trustDiag,
768792
});
769-
emitPluginWarningLog(trustDiag);
793+
trustGrantMessage = formatPluginWarningsSummary(trustDiag.warnings);
770794
if (full !== null) {
771795
livePluginModules = livePluginModules.map((m) =>
772796
m.manifest?.id === id ? full : m,
@@ -798,6 +822,7 @@ export async function runTUI(initialConfig: Config): Promise<number> {
798822
registerCommandPlugin(mod.commandPlugin!);
799823
}
800824
await persistPluginSettings();
825+
return trustGrantMessage === undefined ? undefined : { message: trustGrantMessage };
801826
},
802827
setWebOverride: async (id) => {
803828
liveWebOverride = id;
@@ -813,9 +838,13 @@ export async function runTUI(initialConfig: Config): Promise<number> {
813838
{ [id]: { enabled: true } },
814839
{ diagnostics: verifyDiag },
815840
);
816-
emitPluginWarningLog(verifyDiag);
817841
if (profiles.length === 0) return { ok: false, message: "No valid agent profiles found" };
818-
return { ok: true, message: `loaded — ${profiles.length} profile${profiles.length === 1 ? "" : "s"}` };
842+
// Fold warnings into the message (same pattern as `addPath`) instead of
843+
// logging them: "loaded — N profiles" must not read identically whether
844+
// or not a profile's skill ref actually resolved.
845+
const warnings = formatPluginWarningsSummary(verifyDiag.warnings);
846+
const base = `loaded — ${profiles.length} profile${profiles.length === 1 ? "" : "s"}`;
847+
return { ok: true, message: warnings === undefined ? base : `${base} (${warnings})` };
819848
}
820849
// Tool plugins verify by loading (the factory must construct without
821850
// error and yield at least one tool).
@@ -867,8 +896,17 @@ export async function runTUI(initialConfig: Config): Promise<number> {
867896
if (descriptor === undefined) return { ok: false, message: "Invalid plugin manifest" };
868897
// Persist global path trust only once it resolves to a real plugin, so a
869898
// bogus path never leaves a dangling entry. Expand marketplaces so each
870-
// member is trusted (exact-path match on reload).
871-
const members = await expandPluginPath(abs);
899+
// member is trusted (exact-path match on reload). `onSkip` collects into
900+
// `addDiag` instead of the default stderr write — same reasoning as
901+
// `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+
});
872910
pathTrust = await trustPathPlugins(members.length > 0 ? members : [abs]);
873911
// Replace any existing descriptor/candidate with the same id so re-adding
874912
// refreshes rather than duplicates.
@@ -2047,7 +2085,7 @@ export async function runTUI(initialConfig: Config): Promise<number> {
20472085
},
20482086
setEnabled: async (id, enabled) => {
20492087
const existing = pluginsAdmin.getConfig()[id] ?? {};
2050-
await pluginsAdmin.saveConfig(id, { ...existing, enabled });
2088+
return (await pluginsAdmin.saveConfig(id, { ...existing, enabled })) ?? undefined;
20512089
},
20522090
saveCredentials: async (id, credentials) => {
20532091
const existing = pluginsAdmin.getConfig()[id] ?? {};
@@ -2229,6 +2267,10 @@ export async function runTUI(initialConfig: Config): Promise<number> {
22292267
});
22302268
});
22312269

2270+
// Surface fire-and-forget startup plugin diagnostics now that the shell has
2271+
// a transcript to write into (queued above, before `host` existed).
2272+
for (const notice of startupPluginNotices) systemRow(notice);
2273+
22322274
await host.waitUntilExit();
22332275
// Quitting mid-stream is an abnormal end for the in-flight cycle: nothing
22342276
// downstream delivers its terminal event once the app is gone.

0 commit comments

Comments
 (0)