From 041adb2b1f4f36dc383cd22d59e4862a8cab320a Mon Sep 17 00:00:00 2001 From: can1357 Date: Fri, 24 Jul 2026 01:24:20 +0200 Subject: [PATCH] fix(xdev): tolerated malformed inline-device allowlist config Settings.get returns raw merged config without element validation, so a scalar or non-string tools.xdevInlineDevices entry reached Bun.Glob and threw while building the system prompt. Normalized the allowlist to string patterns and compiled globs once per render. --- packages/coding-agent/src/tools/xdev.ts | 24 ++++++++++++++----- .../test/write-xdev-dispatch.test.ts | 8 +++++++ 2 files changed, 26 insertions(+), 6 deletions(-) diff --git a/packages/coding-agent/src/tools/xdev.ts b/packages/coding-agent/src/tools/xdev.ts index 009567050..caa8b29b4 100644 --- a/packages/coding-agent/src/tools/xdev.ts +++ b/packages/coding-agent/src/tools/xdev.ts @@ -183,6 +183,18 @@ function promptCatalogSummary(inst: Tool, maxLength?: number): string { return `${summary.slice(0, maxLength).trimEnd()}…`; } +/** Compile the `tools.xdevInlineDevices` allowlist once per render, dropping + * non-string entries so malformed user config cannot break prompt builds. */ +function compileInlineGlobs(patterns: readonly string[]): Bun.Glob[] { + if (!Array.isArray(patterns)) return []; + const globs: Bun.Glob[] = []; + for (const pattern of patterns) { + if (typeof pattern !== "string" || pattern.length === 0) continue; + globs.push(new Bun.Glob(pattern)); + } + return globs; +} + /** Decode the (possibly partially streamed) inner args JSON string into display args. */ function decodeInnerArgs(raw: unknown): Record { if (typeof raw !== "string" || raw.length === 0) return {}; @@ -295,9 +307,10 @@ export class XdevRegistry { docsAll(mode: XdevDocsMode = "inline", inlinePatterns: readonly string[] = []): string { const sections: string[] = []; const overflow: Tool[] = []; + const inlineGlobs = compileInlineGlobs(inlinePatterns); let used = 0; for (const tool of this.list()) { - if (!this.#shouldInline(tool, mode, inlinePatterns)) { + if (!this.#shouldInline(tool, mode, inlineGlobs)) { overflow.push(tool); continue; } @@ -329,10 +342,11 @@ export class XdevRegistry { /** Docs for selected mounted devices under the configured prompt-doc policy. */ docsFor(names: Iterable, mode: XdevDocsMode, inlinePatterns: readonly string[] = []): string { const sections: string[] = []; + const inlineGlobs = compileInlineGlobs(inlinePatterns); let used = 0; for (const name of names) { const tool = this.get(name); - if (!tool || !this.#shouldInline(tool, mode, inlinePatterns)) continue; + if (!tool || !this.#shouldInline(tool, mode, inlineGlobs)) continue; const descriptionCap = this.#dynamic.has(tool.name) ? XdevRegistry.EXTERNAL_DESCRIPTION_CAP : undefined; const docs = renderDocs(tool, "##", descriptionCap); if (docs.length > XdevRegistry.DOCS_PER_DEVICE_CAP || used + docs.length > XdevRegistry.DOCS_TOTAL_BUDGET) @@ -343,12 +357,10 @@ export class XdevRegistry { return sections.join("\n\n"); } - #shouldInline(tool: Tool, mode: XdevDocsMode, inlinePatterns: readonly string[]): boolean { + #shouldInline(tool: Tool, mode: XdevDocsMode, inlineGlobs: readonly Bun.Glob[]): boolean { return ( mode !== "catalog" && - (mode === "inline" || - this.#builtins.has(tool.name) || - inlinePatterns.some(pattern => new Bun.Glob(pattern).match(tool.name))) + (mode === "inline" || this.#builtins.has(tool.name) || inlineGlobs.some(glob => glob.match(tool.name))) ); } diff --git a/packages/coding-agent/test/write-xdev-dispatch.test.ts b/packages/coding-agent/test/write-xdev-dispatch.test.ts index 6f6c7f9dd..1ab6bae85 100644 --- a/packages/coding-agent/test/write-xdev-dispatch.test.ts +++ b/packages/coding-agent/test/write-xdev-dispatch.test.ts @@ -258,6 +258,14 @@ describe("read and write route xd:// device URLs", () => { const catalogWithAllowlistDocs = registry.docsAll("catalog", ["mcp__context_mode_*"]); expect(catalogWithAllowlistDocs).not.toContain("## mcp__context_mode_ctx_execute"); + + // Malformed user config (scalar or non-string entries reach the + // registry unvalidated) degrades to the catalog listing instead of + // throwing while the system prompt is built. + const scalarAllowlistDocs = registry.docsAll("builtins", "mcp__context_mode_*" as never); + expect(scalarAllowlistDocs).toContain("- xd://mcp__context_mode_ctx_execute —"); + const nonStringAllowlistDocs = registry.docsAll("builtins", [123] as never); + expect(nonStringAllowlistDocs).toContain("- xd://mcp__context_mode_ctx_execute —"); } finally { await removeWithRetries(tempDir); }