041adb2b1f
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.
293 lines
14 KiB
TypeScript
293 lines
14 KiB
TypeScript
import { describe, expect, it } from "bun:test";
|
|
import * as fs from "node:fs/promises";
|
|
import * as os from "node:os";
|
|
import * as path from "node:path";
|
|
import { Settings } from "@oh-my-pi/pi-coding-agent/config/settings";
|
|
import * as themeModule from "@oh-my-pi/pi-coding-agent/modes/theme/theme";
|
|
import { ToolChoiceQueue } from "@oh-my-pi/pi-coding-agent/session/tool-choice-queue";
|
|
import { createTools, type ToolSession } from "@oh-my-pi/pi-coding-agent/tools";
|
|
import { writeToolRenderer } from "@oh-my-pi/pi-coding-agent/tools/write";
|
|
import { XdevRegistry } from "@oh-my-pi/pi-coding-agent/tools/xdev";
|
|
import { removeWithRetries } from "@oh-my-pi/pi-utils";
|
|
|
|
// xdev mounting is default-on: discoverable tools like ast_edit unmount into
|
|
// xd://, and a plain `write xd://ast_edit` dispatches them. These guard the
|
|
// resolution-device symbols write.ts pulls from ./resolve — a missing import
|
|
// threw `ReferenceError: isResolutionDeviceName is not defined` on *every*
|
|
// xd:// write, in both the executor (approval + execute) and the streaming
|
|
// renderer (surfacing as the error text inside a generic Write frame).
|
|
function xdevSession(cwd: string, overrides: Partial<ToolSession> = {}): ToolSession {
|
|
return {
|
|
cwd,
|
|
hasUI: true,
|
|
getSessionFile: () => null,
|
|
getSessionSpawns: () => "*",
|
|
settings: Settings.isolated({}),
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
describe("read and write route xd:// device URLs", () => {
|
|
it("lists, documents, and dispatches an ast_edit device", async () => {
|
|
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "write-xdev-"));
|
|
try {
|
|
const filePath = path.join(tempDir, "legacy.ts");
|
|
await Bun.write(filePath, "legacyWrap(x, value)\n");
|
|
const queue = new ToolChoiceQueue();
|
|
|
|
const tools = await createTools(
|
|
xdevSession(tempDir, {
|
|
getToolChoiceQueue: () => queue,
|
|
buildToolChoice: () => ({ type: "tool" as const, name: "resolve" }),
|
|
steer: () => {},
|
|
}),
|
|
);
|
|
// xdev on: ast_edit is unmounted into xd://; write stays in the toolset.
|
|
const write = tools.find(entry => entry.name === "write");
|
|
const read = tools.find(entry => entry.name === "read");
|
|
expect(read).toBeDefined();
|
|
expect(write).toBeDefined();
|
|
expect(tools.some(entry => entry.name === "ast_edit")).toBe(false);
|
|
|
|
const listing = await read!.execute("read-xd-list", { path: "xd://" });
|
|
expect(listing.content.find(entry => entry.type === "text")?.text).toContain("xd://ast_edit");
|
|
const docs = await read!.execute("read-xd-docs", { path: "xd://ast_edit" });
|
|
expect(docs.content.find(entry => entry.type === "text")?.text).toContain("# ast_edit");
|
|
|
|
const content = JSON.stringify({
|
|
ops: [{ pat: "legacyWrap($A, $B)", out: "modernWrap($A, $B)" }],
|
|
paths: [filePath],
|
|
});
|
|
|
|
// The write gate decodes the device payload and evaluates the mounted
|
|
// tool's own approval. ast_edit is write-tier for a filesystem path.
|
|
const approval = write!.approval;
|
|
expect(typeof approval).toBe("function");
|
|
if (typeof approval === "function") {
|
|
expect(approval({ path: "xd://ast_edit", content })).toBe("write");
|
|
}
|
|
|
|
// Execute dispatches through the xdev registry to the mounted ast_edit,
|
|
// staging a preview (not a direct apply).
|
|
const previewResult = await write!.execute("write-xdev-preview", { path: "xd://ast_edit", content });
|
|
expect(previewResult.isError).toBeUndefined();
|
|
expect(previewResult.details?.xdev?.tool).toBe("ast_edit");
|
|
expect(previewResult.details?.xdev?.mode).toBe("execute");
|
|
const previewText = previewResult.content.find(entry => entry.type === "text")?.text ?? "";
|
|
expect(previewText).toContain("modernWrap");
|
|
|
|
// The staged preview applies through the resolve queue and rewrites disk.
|
|
const invoker = queue.peekPendingInvoker();
|
|
expect(invoker).toBeDefined();
|
|
await invoker!({ action: "apply", reason: "apply xdev ast edit" });
|
|
expect(await Bun.file(filePath).text()).toContain("modernWrap(x, value)");
|
|
} finally {
|
|
await removeWithRetries(tempDir);
|
|
}
|
|
});
|
|
|
|
it("rejects near-miss xd addresses before filesystem fallback", async () => {
|
|
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "write-xdev-near-miss-"));
|
|
try {
|
|
const tools = await createTools(xdevSession(tempDir));
|
|
const write = tools.find(entry => entry.name === "write");
|
|
expect(write).toBeDefined();
|
|
|
|
for (const target of ["xdt://web_search", "xd:/web_search", "xd/web_search"]) {
|
|
await expect(write!.execute(`write-${target}`, { path: target, content: "{}" })).rejects.toThrow(
|
|
"Did you mean 'xd://web_search'?",
|
|
);
|
|
}
|
|
expect(await Bun.file(path.join(tempDir, "xdt:/web_search")).exists()).toBe(false);
|
|
expect(await Bun.file(path.join(tempDir, "xd/web_search")).exists()).toBe(false);
|
|
|
|
const escaped = await write!.execute("write-explicit-path", {
|
|
path: "./xd/web_search",
|
|
content: "intentional file",
|
|
});
|
|
expect(escaped.isError).toBeUndefined();
|
|
expect(await Bun.file(path.join(tempDir, "xd/web_search")).text()).toBe("intentional file");
|
|
|
|
// conflict:// has no router handler but is a documented write scheme —
|
|
// the guard must let it reach the conflict resolver, not reject it.
|
|
await expect(write!.execute("write-conflict", { path: "conflict://1", content: "x" })).rejects.toThrow(
|
|
"Conflict #1 not found",
|
|
);
|
|
} finally {
|
|
await removeWithRetries(tempDir);
|
|
}
|
|
});
|
|
|
|
it("resolves function-valued device approvals per payload and fails closed on bad content", async () => {
|
|
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "write-xdev-approval-"));
|
|
try {
|
|
const filePath = path.join(tempDir, "target.ts");
|
|
await Bun.write(filePath, "legacyWrap(x, value)\n");
|
|
const tools = await createTools(xdevSession(tempDir));
|
|
const write = tools.find(entry => entry.name === "write");
|
|
expect(write).toBeDefined();
|
|
const approval = write!.approval;
|
|
expect(typeof approval).toBe("function");
|
|
if (typeof approval !== "function") throw new Error("expected a function approval");
|
|
const tier = (path: string, content: string) => approval({ path, content });
|
|
|
|
// ast_edit on a filesystem path → write; on internal URLs only → read.
|
|
const astFsPath = JSON.stringify({
|
|
ops: [{ pat: "legacyWrap($A, $B)", out: "modernWrap($A, $B)" }],
|
|
paths: [filePath],
|
|
});
|
|
const astInternalPath = JSON.stringify({
|
|
ops: [{ pat: "a", out: "b" }],
|
|
paths: ["artifact://abc"],
|
|
});
|
|
expect(tier("xd://ast_edit", astFsPath)).toBe("write");
|
|
expect(tier("xd://ast_edit", astInternalPath)).toBe("read");
|
|
|
|
// debug: inspection action → read; a real launch → exec (control).
|
|
expect(tier("xd://debug", JSON.stringify({ action: "sessions" }))).toBe("read");
|
|
expect(tier("xd://debug", JSON.stringify({ action: "launch", program: "./app" }))).toBe("exec");
|
|
|
|
// Fail closed: malformed JSON, non-object or schema-invalid payloads,
|
|
// missing content, and unknown devices all stay exec so the gate never
|
|
// under-prompts.
|
|
expect(tier("xd://ast_edit", "{ not json")).toBe("exec");
|
|
expect(tier("xd://ast_edit", "[1,2,3]")).toBe("exec");
|
|
expect(tier("xd://ast_edit", '"a string"')).toBe("exec");
|
|
expect(tier("xd://ast_edit", JSON.stringify({ paths: [null] }))).toBe("exec");
|
|
expect(approval({ path: "xd://ast_edit" })).toBe("exec");
|
|
expect(tier("xd://no_such_device", "{}")).toBe("exec");
|
|
} finally {
|
|
await removeWithRetries(tempDir);
|
|
}
|
|
});
|
|
|
|
it("renderCall withholds a partial xd:// URL, then delegates once settled", async () => {
|
|
await themeModule.initTheme();
|
|
const uiTheme = (await themeModule.getThemeByName("dark")) ?? (await themeModule.getThemeByName("light"));
|
|
if (!uiTheme) throw new Error("expected an initialized theme");
|
|
const options = { expanded: false, isPartial: true };
|
|
|
|
const content = JSON.stringify({
|
|
ops: [{ pat: "legacyWrap($A, $B)", out: "modernWrap($A, $B)" }],
|
|
paths: ["/tmp/legacy.ts"],
|
|
});
|
|
|
|
// Path still streaming (no content field yet): render nothing so the user
|
|
// never sees a half-typed "xd://ast_" frame.
|
|
expect(writeToolRenderer.renderCall({ path: "xd://ast_e" }, options, uiTheme)).toBeUndefined();
|
|
|
|
// Path settled + content streaming: delegate to the mounted tool's renderer
|
|
// instead of throwing ReferenceError inside a generic Write frame.
|
|
const rendered = writeToolRenderer.renderCall({ path: "xd://ast_edit", content }, options, uiTheme);
|
|
expect(rendered).toBeDefined();
|
|
});
|
|
|
|
it("docsAll inlines small device docs and falls back to a listing past the caps", async () => {
|
|
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "write-xdev-docs-"));
|
|
try {
|
|
const session = xdevSession(tempDir);
|
|
expect(session.settings.get("tools.xdevDocs")).toBe("builtins");
|
|
await createTools(session);
|
|
const mounted = session.xdevRegistry?.list() ?? [];
|
|
expect(mounted.length).toBeGreaterThan(0);
|
|
|
|
// One device with a pathological description must fall back to the
|
|
// listing without starving the rest of the catalog.
|
|
const giant = Object.create(mounted[0]!) as (typeof mounted)[number];
|
|
Object.defineProperty(giant, "name", { value: "giant_mcp_tool" });
|
|
Object.defineProperty(giant, "description", { value: "x".repeat(XdevRegistry.DOCS_PER_DEVICE_CAP + 1) });
|
|
const registry = new XdevRegistry([...mounted, giant]);
|
|
|
|
const docs = registry.docsAll();
|
|
expect(docs.length).toBeLessThan(XdevRegistry.DOCS_TOTAL_BUDGET + XdevRegistry.DOCS_PER_DEVICE_CAP);
|
|
expect(docs).toContain(`## ${mounted[0]!.name}`);
|
|
expect(docs).toContain("## Additional devices (docs on demand)");
|
|
expect(docs).toContain("- xd://giant_mcp_tool —");
|
|
expect(docs).not.toContain("## giant_mcp_tool");
|
|
} finally {
|
|
await removeWithRetries(tempDir);
|
|
}
|
|
});
|
|
|
|
it("docsAll supports inline, builtins, and catalog prompt modes", async () => {
|
|
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "write-xdev-external-"));
|
|
try {
|
|
const session = xdevSession(tempDir);
|
|
expect(session.settings.get("tools.xdevDocs")).toBe("builtins");
|
|
await createTools(session);
|
|
const registry = session.xdevRegistry;
|
|
if (!registry) throw new Error("expected xdev registry");
|
|
const mounted = registry.list();
|
|
|
|
const longDescription = `LEDE ${"y".repeat(XdevRegistry.EXTERNAL_DESCRIPTION_CAP * 3)} TAIL`;
|
|
const external = Object.create(mounted[0]!) as (typeof mounted)[number];
|
|
Object.defineProperty(external, "name", { value: "mcp_external_tool" });
|
|
Object.defineProperty(external, "description", { value: longDescription });
|
|
Object.defineProperty(external, "summary", {
|
|
value: `SUMMARY ${"z".repeat(XdevRegistry.EXTERNAL_DESCRIPTION_CAP * 3)} TAIL`,
|
|
});
|
|
registry.reconcile([external]);
|
|
|
|
const inlineDocs = registry.docsAll("inline");
|
|
expect(inlineDocs).toContain("## mcp_external_tool");
|
|
expect(inlineDocs).toContain("LEDE ");
|
|
expect(inlineDocs).not.toContain("TAIL");
|
|
expect(inlineDocs).toContain("… (full docs: read xd://mcp_external_tool)");
|
|
|
|
const builtinsDocs = registry.docsAll("builtins");
|
|
expect(builtinsDocs).toContain("## ");
|
|
expect(builtinsDocs).not.toContain("## mcp_external_tool");
|
|
expect(builtinsDocs).toContain("- xd://mcp_external_tool —");
|
|
expect(builtinsDocs).not.toContain("TAIL");
|
|
const catalogDocs = registry.docsAll("catalog");
|
|
expect(catalogDocs).not.toContain(`## ${mounted[0]!.name}`);
|
|
expect(catalogDocs).toContain("- xd://");
|
|
expect(catalogDocs).toContain("- xd://mcp_external_tool —");
|
|
expect(registry.docs("mcp_external_tool")).toContain("TAIL");
|
|
|
|
const contextMode = Object.create(mounted[0]!) as (typeof mounted)[number];
|
|
Object.defineProperty(contextMode, "name", { value: "mcp__context_mode_ctx_execute" });
|
|
const unrelatedMcp = Object.create(mounted[0]!) as (typeof mounted)[number];
|
|
Object.defineProperty(unrelatedMcp, "name", { value: "mcp__other_server_execute" });
|
|
registry.reconcile([contextMode, unrelatedMcp]);
|
|
|
|
const allowlistedDocs = registry.docsAll("builtins", ["mcp__context_mode_*"]);
|
|
expect(allowlistedDocs).toContain("## mcp__context_mode_ctx_execute");
|
|
expect(allowlistedDocs).not.toContain("## mcp__other_server_execute");
|
|
expect(allowlistedDocs).toContain("- xd://mcp__other_server_execute —");
|
|
|
|
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);
|
|
}
|
|
});
|
|
});
|
|
|
|
describe("web_search stays top-level under xdev", () => {
|
|
it("keeps web_search a direct tool and off the xd:// registry with default config", async () => {
|
|
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "write-xdev-websearch-"));
|
|
try {
|
|
const session = xdevSession(tempDir);
|
|
// Default config: tools.xdev is on.
|
|
expect(session.settings.get("tools.xdev")).toBe(true);
|
|
const tools = await createTools(session);
|
|
// Regression for #5973: models call web_search directly, so it must
|
|
// remain a top-level function and never mount behind the xd:// device.
|
|
expect(tools.some(entry => entry.name === "web_search")).toBe(true);
|
|
const mounted = session.xdevRegistry ? [...session.xdevRegistry.list()].map(t => t.name) : [];
|
|
expect(mounted).not.toContain("web_search");
|
|
} finally {
|
|
await removeWithRetries(tempDir);
|
|
}
|
|
});
|
|
});
|