[Init] Initial commit - NetMesh terminal manager
Some checks failed
build-packages / resolve bundled mosh-client (push) Has been cancelled
build-packages / resolve bundled et-client (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
build-packages / update Nix release metadata (push) Has been cancelled
build-packages / bump homebrew tap (push) Has been cancelled
test / lint-and-test (push) Has been cancelled
AI automation / Route event (push) Has been cancelled
AI automation / Hand reopened issue to maintainers (push) Has been cancelled
AI automation / Clean source issue state (push) Has been cancelled
AI automation / Reconcile handoffs (push) Has been cancelled
AI automation / Classify issue (push) Has been cancelled
AI automation / Claude Code smoke (push) Has been cancelled
AI automation / Review issue follow-up (push) Has been cancelled
AI automation / Publish issue follow-up (push) Has been cancelled
AI automation / Implement with Claude Code (push) Has been cancelled
AI automation / Publish implement PR (push) Has been cancelled
AI automation / Continue queued issue comments (push) Has been cancelled
AI automation / Codex review loop (push) Has been cancelled
AI automation / Publish Codex fix (push) Has been cancelled
AI automation / Clear Codex dispatch marker (push) Has been cancelled
AI automation / Own PR re-request Codex (push) Has been cancelled
AI automation / External PR re-request Codex (push) Has been cancelled
AI automation / Poll Codex reaction / retry (push) Has been cancelled
build-et-binaries / build-linux-x64 (push) Has been cancelled
build-et-binaries / build-linux-arm64 (push) Has been cancelled
build-et-binaries / build-macos-universal (push) Has been cancelled
build-et-binaries / build-windows-x64 (push) Has been cancelled
build-et-binaries / release (push) Has been cancelled
Some checks failed
build-packages / resolve bundled mosh-client (push) Has been cancelled
build-packages / resolve bundled et-client (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
build-packages / update Nix release metadata (push) Has been cancelled
build-packages / bump homebrew tap (push) Has been cancelled
test / lint-and-test (push) Has been cancelled
AI automation / Route event (push) Has been cancelled
AI automation / Hand reopened issue to maintainers (push) Has been cancelled
AI automation / Clean source issue state (push) Has been cancelled
AI automation / Reconcile handoffs (push) Has been cancelled
AI automation / Classify issue (push) Has been cancelled
AI automation / Claude Code smoke (push) Has been cancelled
AI automation / Review issue follow-up (push) Has been cancelled
AI automation / Publish issue follow-up (push) Has been cancelled
AI automation / Implement with Claude Code (push) Has been cancelled
AI automation / Publish implement PR (push) Has been cancelled
AI automation / Continue queued issue comments (push) Has been cancelled
AI automation / Codex review loop (push) Has been cancelled
AI automation / Publish Codex fix (push) Has been cancelled
AI automation / Clear Codex dispatch marker (push) Has been cancelled
AI automation / Own PR re-request Codex (push) Has been cancelled
AI automation / External PR re-request Codex (push) Has been cancelled
AI automation / Poll Codex reaction / retry (push) Has been cancelled
build-et-binaries / build-linux-x64 (push) Has been cancelled
build-et-binaries / build-linux-arm64 (push) Has been cancelled
build-et-binaries / build-macos-universal (push) Has been cancelled
build-et-binaries / build-windows-x64 (push) Has been cancelled
build-et-binaries / release (push) Has been cancelled
This commit is contained in:
357
electron/bridges/aiBridge/sdk/copilotDriver.test.cjs
Normal file
357
electron/bridges/aiBridge/sdk/copilotDriver.test.cjs
Normal file
@@ -0,0 +1,357 @@
|
||||
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);
|
||||
});
|
||||
Reference in New Issue
Block a user