358 lines
16 KiB
JavaScript
358 lines
16 KiB
JavaScript
|
|
const test = require("node:test");
|
||
|
|
const assert = require("node:assert/strict");
|
||
|
|
const { approveNetcattyMcpOnly, approveNetcattyCliShellOnly, buildCopilotClientOptions, buildCopilotPermissionHandler, buildCopilotSessionOptions, buildCopilotMessageOptions, copilotBuiltinTools, extractCopilotContent, isLikelyNetcattyCliShellCommand, mapCopilotModels, runCopilotTurn, translateCopilotEvent } = require("./copilotDriver.cjs");
|
||
|
|
|
||
|
|
function collector() {
|
||
|
|
const events = [];
|
||
|
|
return {
|
||
|
|
events,
|
||
|
|
emitter: {
|
||
|
|
text: (t) => events.push({ k: "text", t }),
|
||
|
|
reasoning: (d) => events.push({ k: "reasoning", d }),
|
||
|
|
reasoningEnd: () => events.push({ k: "reasoningEnd" }),
|
||
|
|
toolCall: (n, a, id) => events.push({ k: "toolCall", n, a, id }),
|
||
|
|
toolResult: (id, o, n) => events.push({ k: "toolResult", id, o, n }),
|
||
|
|
sessionId: (s) => events.push({ k: "sessionId", s }),
|
||
|
|
emitError: (e) => events.push({ k: "error", e }),
|
||
|
|
emitDone: () => events.push({ k: "done" }),
|
||
|
|
},
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
/** Minimal @github/copilot-sdk mock; records create vs resume + returns a session. */
|
||
|
|
function makeSdk(captured) {
|
||
|
|
const makeSession = (sessionId) => ({
|
||
|
|
sessionId,
|
||
|
|
async sendAndWait({ prompt }) { captured.prompt = prompt; return { data: { content: "reply:" + sessionId } }; },
|
||
|
|
});
|
||
|
|
class CopilotClient {
|
||
|
|
constructor(options) { captured.clientOptions = options; }
|
||
|
|
async createSession(cfg) { captured.created = cfg; return makeSession("sess-new"); }
|
||
|
|
async resumeSession(id, cfg) { captured.resumed = { id, cfg }; return makeSession(id); }
|
||
|
|
async stop() {}
|
||
|
|
}
|
||
|
|
return { CopilotClient, RuntimeConnection: { forStdio: () => ({}) }, approveAll: () => {} };
|
||
|
|
}
|
||
|
|
|
||
|
|
test("buildCopilotClientOptions pins cliPath", () => {
|
||
|
|
const o = buildCopilotClientOptions({ cliPath: "/abs/copilot" });
|
||
|
|
assert.equal(o.cliPath, "/abs/copilot");
|
||
|
|
});
|
||
|
|
|
||
|
|
test("buildCopilotSessionOptions maps injected MCP to local stdio servers", () => {
|
||
|
|
const o = buildCopilotSessionOptions({
|
||
|
|
model: "claude-sonnet-4.5",
|
||
|
|
injectedMcpServers: [{
|
||
|
|
name: "netcatty-remote-hosts", command: "/abs/electron",
|
||
|
|
args: ["/abs/server.cjs"], env: [{ name: "NETCATTY_MCP_PORT", value: "1" }],
|
||
|
|
}],
|
||
|
|
});
|
||
|
|
assert.equal(o.model, "claude-sonnet-4.5");
|
||
|
|
assert.equal(o.streaming, true);
|
||
|
|
const srv = o.mcpServers["netcatty-remote-hosts"];
|
||
|
|
assert.equal(srv.type, "stdio");
|
||
|
|
assert.equal(srv.command, "/abs/electron");
|
||
|
|
assert.deepEqual(srv.env, { NETCATTY_MCP_PORT: "1" });
|
||
|
|
assert.deepEqual(srv.tools, ["*"]);
|
||
|
|
// onPermissionRequest is wired in runCopilotTurn via the SDK's approveAll,
|
||
|
|
// not in buildCopilotSessionOptions.
|
||
|
|
});
|
||
|
|
|
||
|
|
test("approveNetcattyMcpOnly approves MCP permission requests and rejects local tools", () => {
|
||
|
|
assert.deepEqual(
|
||
|
|
approveNetcattyMcpOnly({ kind: "mcp", toolName: "terminal_execute" }),
|
||
|
|
{ kind: "approve-once" },
|
||
|
|
);
|
||
|
|
assert.deepEqual(
|
||
|
|
approveNetcattyMcpOnly({ kind: "shell", fullCommandText: "rm -rf /tmp/x" }),
|
||
|
|
{ kind: "reject", feedback: "Only Netcatty MCP tools are allowed from this integration." },
|
||
|
|
);
|
||
|
|
assert.deepEqual(
|
||
|
|
approveNetcattyMcpOnly({ kind: "read", fileName: "/etc/passwd" }),
|
||
|
|
{ kind: "reject", feedback: "Only Netcatty MCP tools are allowed from this integration." },
|
||
|
|
);
|
||
|
|
});
|
||
|
|
|
||
|
|
test("extractCopilotContent reads response data.content", () => {
|
||
|
|
assert.equal(extractCopilotContent({ data: { content: "hi" } }), "hi");
|
||
|
|
assert.equal(extractCopilotContent(null), "");
|
||
|
|
assert.equal(extractCopilotContent({ data: {} }), "");
|
||
|
|
});
|
||
|
|
|
||
|
|
test("buildCopilotMessageOptions sends pasted images/files as native attachments", () => {
|
||
|
|
const opts = buildCopilotMessageOptions({
|
||
|
|
prompt: "inspect these",
|
||
|
|
attachments: [
|
||
|
|
{ filename: "shot.png", mediaType: "image/png", filePath: "/tmp/shot.png", base64Data: "abc" },
|
||
|
|
{ filename: "note.txt", mediaType: "text/plain", filePath: "/tmp/note.txt" },
|
||
|
|
],
|
||
|
|
});
|
||
|
|
assert.equal(opts.prompt, "inspect these");
|
||
|
|
assert.equal("streamDeltas" in opts, false);
|
||
|
|
assert.deepEqual(opts.attachments, [
|
||
|
|
{ type: "blob", data: "abc", mimeType: "image/png", displayName: "shot.png" },
|
||
|
|
{ type: "file", path: "/tmp/note.txt", displayName: "note.txt" },
|
||
|
|
]);
|
||
|
|
});
|
||
|
|
|
||
|
|
test("mapCopilotModels maps {id,name} and drops entries without id", () => {
|
||
|
|
const out = mapCopilotModels([
|
||
|
|
{ id: "claude-sonnet-4.5", name: "Claude Sonnet 4.5" },
|
||
|
|
{ id: "gpt-5" },
|
||
|
|
{ name: "no id -> dropped" },
|
||
|
|
]);
|
||
|
|
assert.deepEqual(out, [
|
||
|
|
{ id: "claude-sonnet-4.5", name: "Claude Sonnet 4.5" },
|
||
|
|
{ id: "gpt-5", name: "gpt-5" },
|
||
|
|
]);
|
||
|
|
assert.deepEqual(mapCopilotModels(undefined), []);
|
||
|
|
});
|
||
|
|
|
||
|
|
test("runCopilotTurn (fresh) creates a session, emits its id early, returns it for resume", async () => {
|
||
|
|
const { events, emitter } = collector();
|
||
|
|
const captured = {};
|
||
|
|
const result = await runCopilotTurn({
|
||
|
|
prompt: "hi", clientOptions: { cliPath: "/c" }, sessionOptions: { model: "m" },
|
||
|
|
emitter, sdkModule: makeSdk(captured),
|
||
|
|
});
|
||
|
|
assert.ok(captured.created, "used createSession when there's no resume id");
|
||
|
|
assert.equal(captured.created.model, "m");
|
||
|
|
assert.deepEqual(events.filter((e) => e.k === "sessionId"), [{ k: "sessionId", s: "sess-new" }]);
|
||
|
|
assert.equal(result.sessionId, "sess-new");
|
||
|
|
});
|
||
|
|
|
||
|
|
test("runCopilotTurn resumes the prior session (carry context) and re-applies fresh config", async () => {
|
||
|
|
const { events, emitter } = collector();
|
||
|
|
const captured = {};
|
||
|
|
const result = await runCopilotTurn({
|
||
|
|
prompt: "what did we say", clientOptions: {}, sessionOptions: { model: "m" },
|
||
|
|
resumeSessionId: "sess-existing", emitter, sdkModule: makeSdk(captured),
|
||
|
|
});
|
||
|
|
assert.equal(captured.resumed.id, "sess-existing", "used resumeSession, not createSession");
|
||
|
|
assert.equal(captured.created, undefined);
|
||
|
|
// fresh netcatty MCP/session config re-applied on resume (not the stale one)
|
||
|
|
assert.equal(captured.resumed.cfg.model, "m");
|
||
|
|
assert.equal(result.sessionId, "sess-existing");
|
||
|
|
assert.ok(events.some((e) => e.k === "sessionId" && e.s === "sess-existing"));
|
||
|
|
});
|
||
|
|
|
||
|
|
test("translateCopilotEvent: deltas -> text/reasoning, tool start/complete -> tool card", () => {
|
||
|
|
const { events, emitter } = collector();
|
||
|
|
const state = { reasoningOpen: false, streamedText: false };
|
||
|
|
translateCopilotEvent({ type: "assistant.reasoning_delta", data: { deltaContent: "thinking" } }, emitter, state);
|
||
|
|
translateCopilotEvent({ type: "assistant.message_delta", data: { deltaContent: "hello" } }, emitter, state);
|
||
|
|
translateCopilotEvent({ type: "tool.execution_start", data: { toolName: "shell", arguments: { command: "ls" }, toolCallId: "t1" } }, emitter, state);
|
||
|
|
translateCopilotEvent({ type: "tool.execution_complete", data: { toolCallId: "t1", result: { content: [{ type: "text", text: "files" }] } } }, emitter, state);
|
||
|
|
assert.deepEqual(events, [
|
||
|
|
{ k: "reasoning", d: "thinking" },
|
||
|
|
{ k: "reasoningEnd" }, // message_delta closes the thinking block
|
||
|
|
{ k: "text", t: "hello" },
|
||
|
|
{ k: "toolCall", n: "shell", a: { command: "ls" }, id: "t1" },
|
||
|
|
{ k: "toolResult", id: "t1", o: "files", n: undefined },
|
||
|
|
]);
|
||
|
|
assert.equal(state.streamedText, true);
|
||
|
|
});
|
||
|
|
|
||
|
|
test("translateCopilotEvent: final reasoning is shown when no reasoning deltas streamed", () => {
|
||
|
|
const { events, emitter } = collector();
|
||
|
|
const state = { reasoningOpen: false, streamedText: false, streamedReasoning: false };
|
||
|
|
translateCopilotEvent({ type: "assistant.reasoning", data: { content: "complete thinking" } }, emitter, state);
|
||
|
|
assert.deepEqual(events, [
|
||
|
|
{ k: "reasoning", d: "complete thinking" },
|
||
|
|
{ k: "reasoningEnd" },
|
||
|
|
]);
|
||
|
|
assert.equal(state.reasoningOpen, false);
|
||
|
|
});
|
||
|
|
|
||
|
|
test("translateCopilotEvent: final reasoning is ignored after streamed reasoning deltas", () => {
|
||
|
|
const { events, emitter } = collector();
|
||
|
|
const state = { reasoningOpen: false, streamedText: false, streamedReasoning: false };
|
||
|
|
translateCopilotEvent({ type: "assistant.reasoning_delta", data: { deltaContent: "thinking" } }, emitter, state);
|
||
|
|
translateCopilotEvent({ type: "assistant.reasoning", data: { content: "thinking" } }, emitter, state);
|
||
|
|
translateCopilotEvent({ type: "assistant.message_delta", data: { deltaContent: "hello" } }, emitter, state);
|
||
|
|
assert.deepEqual(events, [
|
||
|
|
{ k: "reasoning", d: "thinking" },
|
||
|
|
{ k: "reasoningEnd" },
|
||
|
|
{ k: "text", t: "hello" },
|
||
|
|
]);
|
||
|
|
});
|
||
|
|
|
||
|
|
test("runCopilotTurn streams tool calls + deltas via session.on (no final-text dup)", async () => {
|
||
|
|
const { events, emitter } = collector();
|
||
|
|
const captured = {};
|
||
|
|
let handler = null;
|
||
|
|
const sdkModule = {
|
||
|
|
RuntimeConnection: { forStdio: () => ({}) },
|
||
|
|
approveAll: () => {},
|
||
|
|
CopilotClient: class {
|
||
|
|
async createSession(cfg) {
|
||
|
|
captured.created = cfg;
|
||
|
|
return {
|
||
|
|
sessionId: "sess-x",
|
||
|
|
on(h) { handler = h; return () => { handler = null; }; },
|
||
|
|
async sendAndWait(opts) {
|
||
|
|
captured.opts = opts;
|
||
|
|
handler({ type: "assistant.message_delta", data: { deltaContent: "hi " } });
|
||
|
|
handler({ type: "tool.execution_start", data: { toolName: "shell", arguments: {}, toolCallId: "t1" } });
|
||
|
|
handler({ type: "tool.execution_complete", data: { toolCallId: "t1", result: { content: [{ type: "text", text: "ok" }] } } });
|
||
|
|
handler({ type: "assistant.message_delta", data: { deltaContent: "there" } });
|
||
|
|
return { data: { content: "hi there" } };
|
||
|
|
},
|
||
|
|
async stop() {},
|
||
|
|
};
|
||
|
|
}
|
||
|
|
async stop() {}
|
||
|
|
},
|
||
|
|
};
|
||
|
|
const result = await runCopilotTurn({
|
||
|
|
prompt: "go",
|
||
|
|
attachments: [{ filename: "shot.png", mediaType: "image/png", filePath: "/tmp/shot.png", base64Data: "abc" }],
|
||
|
|
clientOptions: {},
|
||
|
|
sessionOptions: {},
|
||
|
|
emitter,
|
||
|
|
sdkModule,
|
||
|
|
});
|
||
|
|
assert.equal(captured.created.streaming, true, "requested session streaming");
|
||
|
|
assert.equal("streamDeltas" in captured.opts, false, "does not send unsupported message streaming flag");
|
||
|
|
assert.deepEqual(captured.opts.attachments, [
|
||
|
|
{ type: "blob", data: "abc", mimeType: "image/png", displayName: "shot.png" },
|
||
|
|
]);
|
||
|
|
// streamed deltas shown, NOT the duplicated final consolidated text
|
||
|
|
assert.deepEqual(events.filter((e) => e.k === "text"), [{ k: "text", t: "hi " }, { k: "text", t: "there" }]);
|
||
|
|
assert.ok(events.some((e) => e.k === "toolCall" && e.id === "t1"), "tool card streamed");
|
||
|
|
assert.ok(events.some((e) => e.k === "toolResult" && e.o === "ok"), "tool result streamed");
|
||
|
|
assert.equal(result.sessionId, "sess-x");
|
||
|
|
});
|
||
|
|
|
||
|
|
test("runCopilotTurn aborts the active Copilot session when the signal aborts", async () => {
|
||
|
|
const { events, emitter } = collector();
|
||
|
|
const controller = new AbortController();
|
||
|
|
let abortCalled = false;
|
||
|
|
const sdkModule = {
|
||
|
|
RuntimeConnection: { forStdio: () => ({}) },
|
||
|
|
approveAll: () => {},
|
||
|
|
CopilotClient: class {
|
||
|
|
async createSession() {
|
||
|
|
return {
|
||
|
|
sessionId: "sess-abort",
|
||
|
|
on() { return () => {}; },
|
||
|
|
async sendAndWait() {
|
||
|
|
controller.abort();
|
||
|
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
||
|
|
return { data: { content: "late text" } };
|
||
|
|
},
|
||
|
|
async abort() { abortCalled = true; },
|
||
|
|
};
|
||
|
|
}
|
||
|
|
async stop() {}
|
||
|
|
},
|
||
|
|
};
|
||
|
|
|
||
|
|
const result = await runCopilotTurn({
|
||
|
|
prompt: "stop me",
|
||
|
|
clientOptions: {},
|
||
|
|
sessionOptions: {},
|
||
|
|
emitter,
|
||
|
|
signal: controller.signal,
|
||
|
|
sdkModule,
|
||
|
|
});
|
||
|
|
|
||
|
|
assert.equal(abortCalled, true);
|
||
|
|
assert.equal(result.sessionId, "sess-abort");
|
||
|
|
assert.equal(events.some((event) => event.k === "text" && event.t === "late text"), false);
|
||
|
|
assert.equal(events.some((event) => event.k === "done"), false);
|
||
|
|
});
|
||
|
|
|
||
|
|
test("copilotBuiltinTools exposes bash only in skills mode", () => {
|
||
|
|
assert.equal(copilotBuiltinTools("mcp"), null);
|
||
|
|
assert.deepEqual(copilotBuiltinTools("skills"), ["builtin:bash"]);
|
||
|
|
});
|
||
|
|
|
||
|
|
test("buildCopilotSessionOptions whitelists bash in skills mode", () => {
|
||
|
|
const skills = buildCopilotSessionOptions({
|
||
|
|
model: "gpt-5",
|
||
|
|
injectedMcpServers: [],
|
||
|
|
toolIntegrationMode: "skills",
|
||
|
|
});
|
||
|
|
assert.deepEqual(skills.availableTools, ["builtin:bash"]);
|
||
|
|
assert.deepEqual(skills.mcpServers, {});
|
||
|
|
});
|
||
|
|
|
||
|
|
test("approveNetcattyCliShellOnly allows Netcatty CLI shell commands only", () => {
|
||
|
|
assert.deepEqual(
|
||
|
|
approveNetcattyCliShellOnly({
|
||
|
|
kind: "shell",
|
||
|
|
fullCommandText: 'node "/Applications/Netcatty.app/netcatty-tool-cli.cjs" env --chat-session abc --json',
|
||
|
|
}),
|
||
|
|
{ kind: "approve-once" },
|
||
|
|
);
|
||
|
|
assert.equal(
|
||
|
|
approveNetcattyCliShellOnly({ kind: "shell", fullCommandText: "pwd" }).kind,
|
||
|
|
"reject",
|
||
|
|
);
|
||
|
|
});
|
||
|
|
|
||
|
|
test("buildCopilotPermissionHandler selects MCP vs skills gate", () => {
|
||
|
|
assert.equal(buildCopilotPermissionHandler("mcp"), approveNetcattyMcpOnly);
|
||
|
|
assert.equal(buildCopilotPermissionHandler("skills"), approveNetcattyCliShellOnly);
|
||
|
|
});
|
||
|
|
|
||
|
|
test("isLikelyNetcattyCliShellCommand matches launcher and script invocations", () => {
|
||
|
|
assert.equal(isLikelyNetcattyCliShellCommand("netcatty-tool-cli status --json"), true);
|
||
|
|
assert.equal(isLikelyNetcattyCliShellCommand("node electron/cli/netcatty-tool-cli.cjs env --json"), true);
|
||
|
|
assert.equal(isLikelyNetcattyCliShellCommand("ls -la"), false);
|
||
|
|
});
|
||
|
|
|
||
|
|
test("isLikelyNetcattyCliShellCommand rejects chained or wrapped local commands", () => {
|
||
|
|
assert.equal(isLikelyNetcattyCliShellCommand("rm -rf /; netcatty-tool-cli status --json"), false);
|
||
|
|
assert.equal(isLikelyNetcattyCliShellCommand("netcatty-tool-cli status --json && curl evil"), false);
|
||
|
|
assert.equal(isLikelyNetcattyCliShellCommand('bash -c "netcatty-tool-cli status --json"'), false);
|
||
|
|
assert.equal(isLikelyNetcattyCliShellCommand("malicious netcatty-tool-cli status --json"), false);
|
||
|
|
assert.equal(isLikelyNetcattyCliShellCommand("netcatty-tool-cli status `id` --json"), false);
|
||
|
|
});
|
||
|
|
|
||
|
|
test("isLikelyNetcattyCliShellCommand allows quoted remote exec payloads after --", () => {
|
||
|
|
assert.equal(
|
||
|
|
isLikelyNetcattyCliShellCommand('netcatty-tool-cli exec --session s1 --chat-session c1 --json -- "hostname && whoami"'),
|
||
|
|
true,
|
||
|
|
);
|
||
|
|
assert.equal(
|
||
|
|
isLikelyNetcattyCliShellCommand("netcatty-tool-cli exec --session s1 --chat-session c1 --json -- hostname && whoami"),
|
||
|
|
false,
|
||
|
|
);
|
||
|
|
});
|
||
|
|
|
||
|
|
test("isLikelyNetcattyCliShellCommand rejects impostor binaries and quoted -- bypasses", () => {
|
||
|
|
assert.equal(isLikelyNetcattyCliShellCommand("netcatty-tool-cli-backup status --json"), false);
|
||
|
|
assert.equal(isLikelyNetcattyCliShellCommand("netcatty-tool-cli.evil status --json"), false);
|
||
|
|
assert.equal(
|
||
|
|
isLikelyNetcattyCliShellCommand('netcatty-tool-cli sftp read --remote-path "a -- b" ; rm -rf /'),
|
||
|
|
false,
|
||
|
|
);
|
||
|
|
assert.equal(
|
||
|
|
isLikelyNetcattyCliShellCommand('netcatty-tool-cli sftp read --remote-path "a -- b" --session s1 --json'),
|
||
|
|
true,
|
||
|
|
);
|
||
|
|
assert.equal(isLikelyNetcattyCliShellCommand("netcatty-tool-cli status --json -- ; rm -rf /"), false);
|
||
|
|
assert.equal(isLikelyNetcattyCliShellCommand("netcatty-tool-cli status --json > /tmp/out"), false);
|
||
|
|
assert.equal(isLikelyNetcattyCliShellCommand('"C:\\Apps\\Netcatty\\netcatty-tool-cli.cmd" status --json'), true);
|
||
|
|
assert.equal(isLikelyNetcattyCliShellCommand("attacker/netcatty-tool-cli status --json"), false);
|
||
|
|
assert.equal(isLikelyNetcattyCliShellCommand('netcatty-tool-cli status "$(id)" --json'), false);
|
||
|
|
});
|
||
|
|
|
||
|
|
test("runCopilotTurn passes runtime env and skills permission handler", async () => {
|
||
|
|
const { emitter } = collector();
|
||
|
|
const captured = {};
|
||
|
|
await runCopilotTurn({
|
||
|
|
prompt: "hi",
|
||
|
|
clientOptions: { cliPath: "/c" },
|
||
|
|
sessionOptions: { model: "m" },
|
||
|
|
toolIntegrationMode: "skills",
|
||
|
|
runtimeEnv: { NETCATTY_TOOL_CLI_DISCOVERY_FILE: "/tmp/discovery.json" },
|
||
|
|
emitter,
|
||
|
|
sdkModule: makeSdk(captured),
|
||
|
|
});
|
||
|
|
assert.deepEqual(captured.clientOptions.env, { NETCATTY_TOOL_CLI_DISCOVERY_FILE: "/tmp/discovery.json" });
|
||
|
|
assert.equal(captured.created.onPermissionRequest, approveNetcattyCliShellOnly);
|
||
|
|
});
|