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
1653 lines
55 KiB
JavaScript
1653 lines
55 KiB
JavaScript
const test = require("node:test");
|
||
const assert = require("node:assert/strict");
|
||
const fs = require("node:fs");
|
||
const Module = require("node:module");
|
||
const os = require("node:os");
|
||
const path = require("node:path");
|
||
const http = require("node:http");
|
||
const { prepareCommandForSpawn } = require("./ai/shellUtils.cjs");
|
||
const {
|
||
isCodexAuthError: realIsCodexAuthError,
|
||
normalizeCodexIntegrationState: realNormalizeCodexIntegrationState,
|
||
} = require("./ai/codexHelpers.cjs");
|
||
|
||
function createIpcMainStub() {
|
||
const handlers = new Map();
|
||
return {
|
||
handlers,
|
||
handle(channel, handler) {
|
||
handlers.set(channel, handler);
|
||
},
|
||
};
|
||
}
|
||
|
||
function loadBridgeWithMocks(options = {}) {
|
||
const mocks = {
|
||
"./mcpServerBridge.cjs": {
|
||
init() {},
|
||
setMainWindowGetter() {},
|
||
setVaultAgentInvoker() {},
|
||
getOrCreateHost: async () => 4010,
|
||
getScopedSessionIds: () => [],
|
||
buildMcpServerConfig: () => ({ name: "netcatty-remote-hosts", type: "http", url: "http://127.0.0.1:4010" }),
|
||
getPermissionMode: () =>
|
||
typeof options.getPermissionMode === "function"
|
||
? options.getPermissionMode()
|
||
: "default",
|
||
getMaxIterations: () => 20,
|
||
setCommandTimeout: (...args) => {
|
||
if (typeof options.setCommandTimeout === "function") options.setCommandTimeout(...args);
|
||
},
|
||
updateAttachmentMetadata: (...args) => {
|
||
if (typeof options.updateAttachmentMetadata === "function") options.updateAttachmentMetadata(...args);
|
||
},
|
||
setChatSessionCancelled() {},
|
||
cancelPtyExecsForSession() {},
|
||
clearPendingApprovals() {},
|
||
cleanupScopedMetadata: async () => {},
|
||
cleanup() {},
|
||
},
|
||
"../cli/discoveryPath.cjs": {
|
||
getCliLauncherPath: () => "/tmp/netcatty-tool-cli",
|
||
TOOL_CLI_DISCOVERY_ENV_VAR: "NETCATTY_TOOL_CLI_DISCOVERY_FILE",
|
||
},
|
||
"./ai/userSkills.cjs": {
|
||
scanUserSkills: async () => ({ readyCount: 0, warningCount: 0, skills: [], warnings: [] }),
|
||
buildUserSkillsContext: async () => ({ context: "", selectedSkills: [] }),
|
||
toPublicUserSkillsStatus: (value) => value,
|
||
},
|
||
"./ai/shellUtils.cjs": {
|
||
stripAnsi: (value) => value,
|
||
normalizeCliPathForPlatform: (...args) =>
|
||
typeof options.normalizeCliPathForPlatform === "function"
|
||
? options.normalizeCliPathForPlatform(...args)
|
||
: args[0],
|
||
shouldUseShellForCommand: () => false,
|
||
prepareCommandForSpawn: (...args) =>
|
||
typeof options.prepareCommandForSpawn === "function"
|
||
? options.prepareCommandForSpawn(...args)
|
||
: prepareCommandForSpawn(...args),
|
||
normalizeClaudeCodeExecutableEnvForSdk: (env) =>
|
||
typeof options.normalizeClaudeCodeExecutableEnvForSdk === "function"
|
||
? options.normalizeClaudeCodeExecutableEnvForSdk(env)
|
||
: env,
|
||
isPlausibleCliVersionOutput: (value) =>
|
||
typeof options.isPlausibleCliVersionOutput === "function"
|
||
? options.isPlausibleCliVersionOutput(value)
|
||
: true,
|
||
resolveCliFromPath: (...args) =>
|
||
typeof options.resolveCliFromPath === "function"
|
||
? options.resolveCliFromPath(...args)
|
||
: null,
|
||
resolveCliFromPathAsync: async (...args) =>
|
||
typeof options.resolveCliFromPathAsync === "function"
|
||
? options.resolveCliFromPathAsync(...args)
|
||
: typeof options.resolveCliFromPath === "function"
|
||
? options.resolveCliFromPath(...args)
|
||
: null,
|
||
resolveSdkBinPath: (...args) =>
|
||
typeof options.resolveSdkBinPath === "function"
|
||
? options.resolveSdkBinPath(...args)
|
||
: null,
|
||
resolveSdkBinPathAsync: async (...args) =>
|
||
typeof options.resolveSdkBinPathAsync === "function"
|
||
? options.resolveSdkBinPathAsync(...args)
|
||
: typeof options.resolveSdkBinPath === "function"
|
||
? options.resolveSdkBinPath(...args)
|
||
: null,
|
||
getShellEnv: async () => (
|
||
typeof options.shellEnv === "function"
|
||
? options.shellEnv()
|
||
: options.shellEnv || {}
|
||
),
|
||
invalidateShellEnvCache: () => {
|
||
if (typeof options.invalidateShellEnvCache === "function") options.invalidateShellEnvCache();
|
||
},
|
||
toUnpackedAsarPath: (value) => value,
|
||
},
|
||
"./ai/codexHelpers.cjs": {
|
||
codexLoginSessions: new Map(),
|
||
appendCodexLoginOutput() {},
|
||
toCodexLoginSessionResponse: (session) => ({ sessionId: session.id, codexPath: session.codexPath }),
|
||
getActiveCodexLoginSession: () =>
|
||
typeof options.getActiveCodexLoginSession === "function"
|
||
? options.getActiveCodexLoginSession()
|
||
: null,
|
||
normalizeCodexIntegrationState: (...args) =>
|
||
typeof options.normalizeCodexIntegrationState === "function"
|
||
? options.normalizeCodexIntegrationState(...args)
|
||
: realNormalizeCodexIntegrationState(...args),
|
||
appendCodexChatGptValidationFailure: (rawOutput, validationError) =>
|
||
`${rawOutput}\n\nChatGPT auth validation failed:\n${validationError}`.trim(),
|
||
readCodexCustomProviderConfig: () => null,
|
||
getCodexCustomConfigPreflightError: () => null,
|
||
extractCodexError: (err) => ({ message: err?.message || String(err) }),
|
||
isCodexAuthError: (...args) =>
|
||
typeof options.isCodexAuthError === "function"
|
||
? options.isCodexAuthError(...args)
|
||
: realIsCodexAuthError(...args),
|
||
getCodexAuthFingerprint: (...args) =>
|
||
typeof options.getCodexAuthFingerprint === "function"
|
||
? options.getCodexAuthFingerprint(...args)
|
||
: "auth-fingerprint",
|
||
getCodexMcpFingerprint: () => "mcp-fingerprint",
|
||
invalidateCodexValidationCache() {},
|
||
getCodexValidationCache: () => null,
|
||
setCodexValidationCache() {},
|
||
},
|
||
"./aiBridge/agentAuthProbes.cjs": {
|
||
probeClaudeAuth: (...args) =>
|
||
typeof options.probeClaudeAuth === "function" ? options.probeClaudeAuth(...args) : { authenticated: false, authSource: null },
|
||
probeCopilotAuth: (...args) =>
|
||
typeof options.probeCopilotAuth === "function" ? options.probeCopilotAuth(...args) : { authenticated: false, authSource: null },
|
||
probeCodexAuth: (...args) =>
|
||
typeof options.probeCodexAuth === "function" ? options.probeCodexAuth(...args) : { authenticated: false, authSource: null },
|
||
probeCodebuddyAuth: (...args) =>
|
||
typeof options.probeCodebuddyAuth === "function"
|
||
? options.probeCodebuddyAuth(...args)
|
||
: { authenticated: false, authSource: null },
|
||
probeCursorCliAuth: (...args) =>
|
||
typeof options.probeCursorCliAuth === "function"
|
||
? options.probeCursorCliAuth(...args)
|
||
: { authenticated: false, authSource: null, email: null, binPath: null },
|
||
probeGrokAuth: (...args) =>
|
||
typeof options.probeGrokAuth === "function"
|
||
? options.probeGrokAuth(...args)
|
||
: { authenticated: false, authSource: null },
|
||
},
|
||
"./ai/ptyExec.cjs": {
|
||
execViaPty: async () => {
|
||
throw new Error("execViaPty should not be called in this test");
|
||
},
|
||
},
|
||
"./ipcUtils.cjs": {
|
||
safeSend: (...args) => {
|
||
if (typeof options.safeSend === "function") options.safeSend(...args);
|
||
},
|
||
},
|
||
"./windowManager.cjs": {
|
||
getMainWindow() {
|
||
return {
|
||
isDestroyed: () => false,
|
||
webContents: { id: 1 },
|
||
};
|
||
},
|
||
getSettingsWindow() {
|
||
return null;
|
||
},
|
||
},
|
||
};
|
||
if (typeof options.registerSdkStreamHandlers === "function") {
|
||
mocks["./aiBridge/sdk/sdkStreamHandlers.cjs"] = {
|
||
registerSdkStreamHandlers: options.registerSdkStreamHandlers,
|
||
};
|
||
}
|
||
|
||
const bridgePath = require.resolve("./aiBridge.cjs");
|
||
const originalLoad = Module._load;
|
||
Module._load = function patchedLoad(request, parent, isMain) {
|
||
if (Object.prototype.hasOwnProperty.call(mocks, request)) {
|
||
return mocks[request];
|
||
}
|
||
return originalLoad.call(this, request, parent, isMain);
|
||
};
|
||
delete require.cache[bridgePath];
|
||
|
||
try {
|
||
const bridge = require("./aiBridge.cjs");
|
||
return {
|
||
bridge,
|
||
restore() {
|
||
try {
|
||
bridge.cleanup();
|
||
} finally {
|
||
delete require.cache[bridgePath];
|
||
Module._load = originalLoad;
|
||
}
|
||
},
|
||
};
|
||
} catch (error) {
|
||
delete require.cache[bridgePath];
|
||
Module._load = originalLoad;
|
||
throw error;
|
||
}
|
||
}
|
||
|
||
test("cleanup closes persistent CodeBuddy sessions", () => {
|
||
let codebuddyCloseCalls = 0;
|
||
let codexCloseCalls = 0;
|
||
let sdkAbortCalls = 0;
|
||
const { bridge, restore } = loadBridgeWithMocks({
|
||
registerSdkStreamHandlers: (context) => {
|
||
context.sdkActiveStreams = new Map([
|
||
["request-1", { abort: () => { sdkAbortCalls += 1; } }],
|
||
]);
|
||
context.codexAppServerRuntime = {
|
||
close: () => { codexCloseCalls += 1; },
|
||
};
|
||
context.codebuddySessionManager = {
|
||
closeAll: () => { codebuddyCloseCalls += 1; },
|
||
};
|
||
},
|
||
});
|
||
const ipcMain = createIpcMainStub();
|
||
|
||
bridge.init({
|
||
sessions: new Map(),
|
||
sftpClients: new Map(),
|
||
electronModule: { app: { getPath: () => process.cwd() } },
|
||
});
|
||
bridge.registerHandlers(ipcMain);
|
||
|
||
try {
|
||
bridge.cleanup();
|
||
assert.equal(sdkAbortCalls, 1);
|
||
assert.equal(codexCloseCalls, 1);
|
||
assert.equal(codebuddyCloseCalls, 1);
|
||
} finally {
|
||
restore();
|
||
}
|
||
});
|
||
|
||
test("non-2xx streaming responses are bounded and actively terminated", async () => {
|
||
let requestClosed = false;
|
||
let resolveRequestClosed;
|
||
const requestClosedPromise = new Promise((resolve) => { resolveRequestClosed = resolve; });
|
||
const server = http.createServer((_request, response) => {
|
||
response.writeHead(500, { "content-type": "text/plain" });
|
||
const interval = setInterval(() => response.write("abcdefgh"), 2);
|
||
response.on("close", () => {
|
||
clearInterval(interval);
|
||
requestClosed = true;
|
||
resolveRequestClosed();
|
||
});
|
||
});
|
||
await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
|
||
const address = server.address();
|
||
const { bridge, restore } = loadBridgeWithMocks();
|
||
bridge.init({
|
||
sessions: new Map(),
|
||
sftpClients: new Map(),
|
||
electronModule: { app: { getPath: () => process.cwd() }, session: {} },
|
||
});
|
||
|
||
try {
|
||
await assert.rejects(
|
||
() => bridge._streamRequestForTests(
|
||
`http://127.0.0.1:${address.port}/streaming-error`,
|
||
{
|
||
method: "GET",
|
||
maxErrorBodyBytes: 32,
|
||
totalTimeoutMs: 1_000,
|
||
},
|
||
{ sender: { id: 1 } },
|
||
"streaming-error",
|
||
false,
|
||
),
|
||
/error response exceeded/i,
|
||
);
|
||
await Promise.race([
|
||
requestClosedPromise,
|
||
new Promise((_, reject) => setTimeout(() => reject(new Error("server request stayed open")), 500)),
|
||
]);
|
||
assert.equal(requestClosed, true);
|
||
assert.equal(bridge._getActiveStreamCountForTests(), 0);
|
||
} finally {
|
||
restore();
|
||
await new Promise((resolve) => server.close(resolve));
|
||
}
|
||
});
|
||
|
||
test("AI stream default total timeout is long enough for extended reasoning", () => {
|
||
const { bridge, restore } = loadBridgeWithMocks();
|
||
try {
|
||
assert.equal(bridge.DEFAULT_AI_STREAM_IDLE_TIMEOUT_MS, 120_000);
|
||
assert.equal(bridge.DEFAULT_AI_STREAM_TOTAL_TIMEOUT_MS, 30 * 60 * 1000);
|
||
assert.ok(bridge.DEFAULT_AI_STREAM_TOTAL_TIMEOUT_MS > bridge.DEFAULT_AI_STREAM_IDLE_TIMEOUT_MS);
|
||
} finally {
|
||
restore();
|
||
}
|
||
});
|
||
|
||
test("AI stream total timeout never undercuts the configured idle timeout", () => {
|
||
const { bridge, restore } = loadBridgeWithMocks();
|
||
try {
|
||
const timeouts = bridge._resolveAIStreamTimeoutsForTests({
|
||
idleTimeoutMs: 60 * 60 * 1000,
|
||
totalTimeoutMs: 30 * 60 * 1000,
|
||
});
|
||
assert.equal(timeouts.idleTimeoutMs, 60 * 60 * 1000);
|
||
assert.ok(timeouts.totalTimeoutMs > timeouts.idleTimeoutMs);
|
||
} finally {
|
||
restore();
|
||
}
|
||
});
|
||
|
||
test("streaming requests enforce a total deadline even while bytes keep arriving", async () => {
|
||
let requestClosed = false;
|
||
let resolveRequestClosed;
|
||
const requestClosedPromise = new Promise((resolve) => { resolveRequestClosed = resolve; });
|
||
const server = http.createServer((_request, response) => {
|
||
response.writeHead(500, { "content-type": "text/plain" });
|
||
const interval = setInterval(() => response.write("x"), 2);
|
||
response.on("close", () => {
|
||
clearInterval(interval);
|
||
requestClosed = true;
|
||
resolveRequestClosed();
|
||
});
|
||
});
|
||
await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
|
||
const address = server.address();
|
||
const { bridge, restore } = loadBridgeWithMocks();
|
||
bridge.init({
|
||
sessions: new Map(),
|
||
sftpClients: new Map(),
|
||
electronModule: { app: { getPath: () => process.cwd() }, session: {} },
|
||
});
|
||
|
||
try {
|
||
await assert.rejects(
|
||
() => bridge._streamRequestForTests(
|
||
`http://127.0.0.1:${address.port}/never-ending`,
|
||
{
|
||
method: "GET",
|
||
maxErrorBodyBytes: 1024 * 1024,
|
||
totalTimeoutMs: 30,
|
||
},
|
||
{ sender: { id: 1 } },
|
||
"never-ending",
|
||
false,
|
||
),
|
||
/total deadline exceeded/i,
|
||
);
|
||
await Promise.race([
|
||
requestClosedPromise,
|
||
new Promise((_, reject) => setTimeout(() => reject(new Error("server request stayed open")), 500)),
|
||
]);
|
||
assert.equal(requestClosed, true);
|
||
assert.equal(bridge._getActiveStreamCountForTests(), 0);
|
||
} finally {
|
||
restore();
|
||
await new Promise((resolve) => server.close(resolve));
|
||
}
|
||
});
|
||
|
||
test("streaming requests do not abort active thinking output at the idle deadline", async () => {
|
||
let requestClosed = false;
|
||
let resolveRequestClosed;
|
||
const requestClosedPromise = new Promise((resolve) => { resolveRequestClosed = resolve; });
|
||
const server = http.createServer((_request, response) => {
|
||
response.writeHead(200, { "content-type": "text/event-stream" });
|
||
let n = 0;
|
||
const interval = setInterval(() => {
|
||
n += 1;
|
||
response.write(`data:{"choices":[{"delta":{"reasoning_content":"${n}"}}]}\n`);
|
||
if (n >= 6) {
|
||
clearInterval(interval);
|
||
response.end("data:[DONE]\n\n");
|
||
}
|
||
}, 25);
|
||
response.on("close", () => {
|
||
clearInterval(interval);
|
||
requestClosed = true;
|
||
resolveRequestClosed();
|
||
});
|
||
});
|
||
await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
|
||
const address = server.address();
|
||
const sentEvents = [];
|
||
const { bridge, restore } = loadBridgeWithMocks({
|
||
safeSend: (_sender, channel, payload) => {
|
||
sentEvents.push({ channel, payload });
|
||
},
|
||
});
|
||
bridge.init({
|
||
sessions: new Map(),
|
||
sftpClients: new Map(),
|
||
electronModule: { app: { getPath: () => process.cwd() }, session: {} },
|
||
});
|
||
|
||
try {
|
||
const result = await bridge._streamRequestForTests(
|
||
`http://127.0.0.1:${address.port}/thinking`,
|
||
{
|
||
method: "GET",
|
||
idleTimeoutMs: 80,
|
||
totalTimeoutMs: 2_000,
|
||
},
|
||
{ sender: { id: 1 } },
|
||
"thinking",
|
||
false,
|
||
);
|
||
assert.equal(result.statusCode, 200);
|
||
await Promise.race([
|
||
new Promise((resolve, reject) => {
|
||
const started = Date.now();
|
||
const poll = () => {
|
||
if (sentEvents.some(({ channel }) => channel === "netcatty:ai:stream:end")) {
|
||
resolve();
|
||
return;
|
||
}
|
||
const errorEvent = sentEvents.find(({ channel }) => channel === "netcatty:ai:stream:error");
|
||
if (errorEvent) {
|
||
reject(new Error(errorEvent.payload.error));
|
||
return;
|
||
}
|
||
if (Date.now() - started > 1_500) {
|
||
reject(new Error("thinking stream never finished"));
|
||
return;
|
||
}
|
||
setTimeout(poll, 10);
|
||
};
|
||
poll();
|
||
}),
|
||
]);
|
||
await Promise.race([
|
||
requestClosedPromise,
|
||
new Promise((_, reject) => setTimeout(() => reject(new Error("server request stayed open")), 500)),
|
||
]);
|
||
assert.equal(requestClosed, true);
|
||
assert.equal(bridge._getActiveStreamCountForTests(), 0);
|
||
assert.equal(
|
||
sentEvents.some(({ channel }) => channel === "netcatty:ai:stream:error"),
|
||
false,
|
||
);
|
||
} finally {
|
||
restore();
|
||
await new Promise((resolve) => server.close(resolve));
|
||
}
|
||
});
|
||
|
||
test("streaming requests abort after the idle deadline when no more bytes arrive", async () => {
|
||
let requestClosed = false;
|
||
let resolveRequestClosed;
|
||
const requestClosedPromise = new Promise((resolve) => { resolveRequestClosed = resolve; });
|
||
const server = http.createServer((_request, response) => {
|
||
response.writeHead(200, { "content-type": "text/event-stream" });
|
||
response.write('data:{"choices":[{"delta":{"reasoning_content":"start"}}]}\n');
|
||
response.on("close", () => {
|
||
requestClosed = true;
|
||
resolveRequestClosed();
|
||
});
|
||
});
|
||
await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
|
||
const address = server.address();
|
||
const sentEvents = [];
|
||
const { bridge, restore } = loadBridgeWithMocks({
|
||
safeSend: (_sender, channel, payload) => {
|
||
sentEvents.push({ channel, payload });
|
||
},
|
||
});
|
||
bridge.init({
|
||
sessions: new Map(),
|
||
sftpClients: new Map(),
|
||
electronModule: { app: { getPath: () => process.cwd() }, session: {} },
|
||
});
|
||
|
||
try {
|
||
const result = await bridge._streamRequestForTests(
|
||
`http://127.0.0.1:${address.port}/stalled`,
|
||
{
|
||
method: "GET",
|
||
idleTimeoutMs: 40,
|
||
totalTimeoutMs: 2_000,
|
||
},
|
||
{ sender: { id: 1 } },
|
||
"stalled",
|
||
false,
|
||
);
|
||
assert.equal(result.statusCode, 200);
|
||
await Promise.race([
|
||
new Promise((resolve, reject) => {
|
||
const started = Date.now();
|
||
const poll = () => {
|
||
const errorEvent = sentEvents.find(({ channel }) => channel === "netcatty:ai:stream:error");
|
||
if (errorEvent) {
|
||
resolve(errorEvent.payload.error);
|
||
return;
|
||
}
|
||
if (Date.now() - started > 1_000) {
|
||
reject(new Error("stalled stream was not aborted"));
|
||
return;
|
||
}
|
||
setTimeout(poll, 10);
|
||
};
|
||
poll();
|
||
}).then((error) => {
|
||
assert.match(error, /idle deadline exceeded/i);
|
||
}),
|
||
]);
|
||
await Promise.race([
|
||
requestClosedPromise,
|
||
new Promise((_, reject) => setTimeout(() => reject(new Error("server request stayed open")), 500)),
|
||
]);
|
||
assert.equal(requestClosed, true);
|
||
assert.equal(bridge._getActiveStreamCountForTests(), 0);
|
||
} finally {
|
||
restore();
|
||
await new Promise((resolve) => server.close(resolve));
|
||
}
|
||
});
|
||
|
||
test("streaming chat handler forwards the configured idle deadline", { timeout: 5_000 }, async (t) => {
|
||
const sentEvents = [];
|
||
const server = http.createServer((request, response) => {
|
||
request.resume();
|
||
request.on("end", () => {
|
||
response.writeHead(200, { "content-type": "text/event-stream" });
|
||
response.write('data:{"choices":[{"delta":{"content":"start"}}]}\n\n');
|
||
});
|
||
});
|
||
await new Promise((resolve, reject) => {
|
||
server.once("error", reject);
|
||
server.listen(0, "127.0.0.1", resolve);
|
||
});
|
||
t.after(() => new Promise((resolve) => server.close(resolve)));
|
||
|
||
const { bridge, restore } = loadBridgeWithMocks({
|
||
safeSend: (_sender, channel, payload) => sentEvents.push({ channel, payload }),
|
||
});
|
||
t.after(restore);
|
||
const ipcMain = createIpcMainStub();
|
||
bridge.init({
|
||
sessions: new Map(),
|
||
sftpClients: new Map(),
|
||
electronModule: { app: { getPath: () => process.cwd() }, session: {} },
|
||
});
|
||
bridge.registerHandlers(ipcMain);
|
||
|
||
const address = server.address();
|
||
assert.ok(address && typeof address === "object");
|
||
const baseURL = `http://127.0.0.1:${address.port}`;
|
||
const sender = { id: 1 };
|
||
await ipcMain.handlers.get("netcatty:ai:sync-providers")(
|
||
{ sender },
|
||
{ providers: [{ id: "custom-idle", baseURL }] },
|
||
);
|
||
|
||
const result = await ipcMain.handlers.get("netcatty:ai:chat:stream")(
|
||
{ sender },
|
||
{
|
||
requestId: "custom-idle-request",
|
||
url: `${baseURL}/v1/chat/completions`,
|
||
headers: { "content-type": "application/json" },
|
||
body: '{"stream":true}',
|
||
providerId: "custom-idle",
|
||
idleTimeoutMs: 40,
|
||
},
|
||
);
|
||
assert.equal(result.ok, true);
|
||
|
||
const error = await new Promise((resolve, reject) => {
|
||
const started = Date.now();
|
||
const poll = () => {
|
||
const event = sentEvents.find(({ channel }) => channel === "netcatty:ai:stream:error");
|
||
if (event) {
|
||
resolve(event.payload.error);
|
||
return;
|
||
}
|
||
if (Date.now() - started > 1_000) {
|
||
reject(new Error("configured stream idle deadline was not forwarded"));
|
||
return;
|
||
}
|
||
setTimeout(poll, 10);
|
||
};
|
||
poll();
|
||
});
|
||
assert.match(error, /idle deadline exceeded|request timeout/i);
|
||
});
|
||
|
||
test("mcp attachment update handler forwards current chat attachments", async () => {
|
||
const calls = [];
|
||
const { bridge, restore } = loadBridgeWithMocks({
|
||
updateAttachmentMetadata: (attachments, chatSessionId) => calls.push({ attachments, chatSessionId }),
|
||
});
|
||
const ipcMain = createIpcMainStub();
|
||
|
||
bridge.init({
|
||
sessions: new Map(),
|
||
sftpClients: new Map(),
|
||
electronModule: { app: { getPath: () => process.cwd() } },
|
||
});
|
||
bridge.registerHandlers(ipcMain);
|
||
|
||
try {
|
||
const updateAttachments = ipcMain.handlers.get("netcatty:ai:mcp:update-attachments");
|
||
assert.equal(typeof updateAttachments, "function");
|
||
const attachments = [{
|
||
filename: "hosts_export_2026-06-25.csv",
|
||
mediaType: "text/csv",
|
||
base64Data: Buffer.from("label,hostname\nprod,prod.example.com\n").toString("base64"),
|
||
filePath: "/tmp/hosts_export_2026-06-25.csv",
|
||
}];
|
||
|
||
const result = await updateAttachments({ sender: { id: 1 } }, {
|
||
attachments,
|
||
chatSessionId: "chat-1",
|
||
});
|
||
|
||
assert.deepEqual(result, { ok: true });
|
||
assert.deepEqual(calls, [{ attachments, chatSessionId: "chat-1" }]);
|
||
} finally {
|
||
restore();
|
||
}
|
||
});
|
||
|
||
test("command timeout handler accepts one-day timeout values", async () => {
|
||
const calls = [];
|
||
const { bridge, restore } = loadBridgeWithMocks({
|
||
setCommandTimeout: (value) => calls.push(value),
|
||
});
|
||
const ipcMain = createIpcMainStub();
|
||
|
||
bridge.init({
|
||
sessions: new Map(),
|
||
sftpClients: new Map(),
|
||
electronModule: { app: { getPath: () => process.cwd() } },
|
||
});
|
||
bridge.registerHandlers(ipcMain);
|
||
|
||
try {
|
||
const setCommandTimeout = ipcMain.handlers.get("netcatty:ai:mcp:set-command-timeout");
|
||
assert.equal(typeof setCommandTimeout, "function");
|
||
|
||
const result = await setCommandTimeout({ sender: { id: 1 } }, { timeout: 86_400 });
|
||
|
||
assert.deepEqual(result, { ok: true });
|
||
assert.deepEqual(calls, [86_400]);
|
||
} finally {
|
||
restore();
|
||
}
|
||
});
|
||
|
||
test("streaming AI responses preserve UTF-8 characters split across network chunks", { timeout: 5_000 }, async (t) => {
|
||
const sentEvents = [];
|
||
let handleStreamEvent = () => {};
|
||
const { bridge, restore } = loadBridgeWithMocks({
|
||
safeSend: (_sender, channel, payload) => {
|
||
sentEvents.push({ channel, payload });
|
||
handleStreamEvent(channel, payload);
|
||
},
|
||
});
|
||
const ipcMain = createIpcMainStub();
|
||
const server = require("node:http").createServer((req, res) => {
|
||
req.resume();
|
||
req.on("end", () => {
|
||
res.writeHead(200, { "content-type": "text/event-stream" });
|
||
const message = Buffer.from('data: {"choices":[{"delta":{"content":"环境正常"}}]}\n\n');
|
||
const splitAt = message.indexOf(Buffer.from("环")) + 1;
|
||
res.write(message.subarray(0, splitAt));
|
||
setImmediate(() => res.end(message.subarray(splitAt)));
|
||
});
|
||
});
|
||
|
||
await new Promise((resolve, reject) => {
|
||
server.once("error", reject);
|
||
server.listen(0, "127.0.0.1", resolve);
|
||
});
|
||
t.after(() => new Promise((resolve) => server.close(resolve)));
|
||
|
||
bridge.init({
|
||
sessions: new Map(),
|
||
sftpClients: new Map(),
|
||
electronModule: { app: { getPath: () => process.cwd() } },
|
||
});
|
||
bridge.registerHandlers(ipcMain);
|
||
|
||
try {
|
||
const address = server.address();
|
||
assert.ok(address && typeof address === "object");
|
||
const baseURL = `http://127.0.0.1:${address.port}`;
|
||
const sender = { id: 1 };
|
||
await ipcMain.handlers.get("netcatty:ai:sync-providers")(
|
||
{ sender },
|
||
{ providers: [{ id: "split-utf8", baseURL }] },
|
||
);
|
||
|
||
const streamFinished = new Promise((resolve, reject) => {
|
||
handleStreamEvent = (channel, payload) => {
|
||
if (channel === "netcatty:ai:stream:end") resolve();
|
||
else if (channel === "netcatty:ai:stream:error") reject(new Error(payload.error));
|
||
};
|
||
});
|
||
|
||
const result = await ipcMain.handlers.get("netcatty:ai:chat:stream")(
|
||
{ sender },
|
||
{
|
||
requestId: "split-utf8-request",
|
||
url: `${baseURL}/v1/chat/completions`,
|
||
headers: { "content-type": "application/json" },
|
||
body: '{"stream":true}',
|
||
providerId: "split-utf8",
|
||
},
|
||
);
|
||
await streamFinished;
|
||
|
||
assert.equal(result.ok, true);
|
||
const dataEvent = sentEvents.find(({ channel }) => channel === "netcatty:ai:stream:data");
|
||
assert.equal(dataEvent?.payload.data, '{"choices":[{"delta":{"content":"环境正常"}}]}');
|
||
} finally {
|
||
restore();
|
||
}
|
||
});
|
||
|
||
test("streaming AI responses accept SSE data: lines without a space after the colon", { timeout: 5_000 }, async (t) => {
|
||
const sentEvents = [];
|
||
let handleStreamEvent = () => {};
|
||
const { bridge, restore } = loadBridgeWithMocks({
|
||
safeSend: (_sender, channel, payload) => {
|
||
sentEvents.push({ channel, payload });
|
||
handleStreamEvent(channel, payload);
|
||
},
|
||
});
|
||
const ipcMain = createIpcMainStub();
|
||
const server = require("node:http").createServer((req, res) => {
|
||
req.resume();
|
||
req.on("end", () => {
|
||
res.writeHead(200, { "content-type": "text/event-stream" });
|
||
// Older AxonHub 0.9 emits `data:{json}` with no space (issue #3020).
|
||
res.end('data:{"choices":[{"delta":{"content":"hello"}}]}\ndata:[DONE]\n\n');
|
||
});
|
||
});
|
||
|
||
await new Promise((resolve, reject) => {
|
||
server.once("error", reject);
|
||
server.listen(0, "127.0.0.1", resolve);
|
||
});
|
||
t.after(() => new Promise((resolve) => server.close(resolve)));
|
||
|
||
bridge.init({
|
||
sessions: new Map(),
|
||
sftpClients: new Map(),
|
||
electronModule: { app: { getPath: () => process.cwd() } },
|
||
});
|
||
bridge.registerHandlers(ipcMain);
|
||
|
||
try {
|
||
const address = server.address();
|
||
assert.ok(address && typeof address === "object");
|
||
const baseURL = `http://127.0.0.1:${address.port}`;
|
||
const sender = { id: 1 };
|
||
await ipcMain.handlers.get("netcatty:ai:sync-providers")(
|
||
{ sender },
|
||
{ providers: [{ id: "axonhub-legacy", baseURL }] },
|
||
);
|
||
|
||
const streamFinished = new Promise((resolve, reject) => {
|
||
handleStreamEvent = (channel, payload) => {
|
||
if (channel === "netcatty:ai:stream:end") resolve();
|
||
else if (channel === "netcatty:ai:stream:error") reject(new Error(payload.error));
|
||
};
|
||
});
|
||
|
||
const result = await ipcMain.handlers.get("netcatty:ai:chat:stream")(
|
||
{ sender },
|
||
{
|
||
requestId: "axonhub-legacy-request",
|
||
url: `${baseURL}/v1/chat/completions`,
|
||
headers: { "content-type": "application/json" },
|
||
body: '{"stream":true}',
|
||
providerId: "axonhub-legacy",
|
||
},
|
||
);
|
||
await streamFinished;
|
||
|
||
assert.equal(result.ok, true);
|
||
const dataEvents = sentEvents
|
||
.filter(({ channel }) => channel === "netcatty:ai:stream:data")
|
||
.map(({ payload }) => payload.data);
|
||
assert.deepEqual(dataEvents, [
|
||
'{"choices":[{"delta":{"content":"hello"}}]}',
|
||
"[DONE]",
|
||
]);
|
||
} finally {
|
||
restore();
|
||
}
|
||
});
|
||
|
||
test("streaming AI responses flush a trailing data: line that has no space and no newline", { timeout: 5_000 }, async (t) => {
|
||
const sentEvents = [];
|
||
let handleStreamEvent = () => {};
|
||
const { bridge, restore } = loadBridgeWithMocks({
|
||
safeSend: (_sender, channel, payload) => {
|
||
sentEvents.push({ channel, payload });
|
||
handleStreamEvent(channel, payload);
|
||
},
|
||
});
|
||
const ipcMain = createIpcMainStub();
|
||
const server = require("node:http").createServer((req, res) => {
|
||
req.resume();
|
||
req.on("end", () => {
|
||
res.writeHead(200, { "content-type": "text/event-stream" });
|
||
res.end('data:{"choices":[{"delta":{"content":"tail"}}]}');
|
||
});
|
||
});
|
||
|
||
await new Promise((resolve, reject) => {
|
||
server.once("error", reject);
|
||
server.listen(0, "127.0.0.1", resolve);
|
||
});
|
||
t.after(() => new Promise((resolve) => server.close(resolve)));
|
||
|
||
bridge.init({
|
||
sessions: new Map(),
|
||
sftpClients: new Map(),
|
||
electronModule: { app: { getPath: () => process.cwd() } },
|
||
});
|
||
bridge.registerHandlers(ipcMain);
|
||
|
||
try {
|
||
const address = server.address();
|
||
assert.ok(address && typeof address === "object");
|
||
const baseURL = `http://127.0.0.1:${address.port}`;
|
||
const sender = { id: 1 };
|
||
await ipcMain.handlers.get("netcatty:ai:sync-providers")(
|
||
{ sender },
|
||
{ providers: [{ id: "flush-no-space", baseURL }] },
|
||
);
|
||
|
||
const streamFinished = new Promise((resolve, reject) => {
|
||
handleStreamEvent = (channel, payload) => {
|
||
if (channel === "netcatty:ai:stream:end") resolve();
|
||
else if (channel === "netcatty:ai:stream:error") reject(new Error(payload.error));
|
||
};
|
||
});
|
||
|
||
const result = await ipcMain.handlers.get("netcatty:ai:chat:stream")(
|
||
{ sender },
|
||
{
|
||
requestId: "flush-no-space-request",
|
||
url: `${baseURL}/v1/chat/completions`,
|
||
headers: { "content-type": "application/json" },
|
||
body: '{"stream":true}',
|
||
providerId: "flush-no-space",
|
||
},
|
||
);
|
||
await streamFinished;
|
||
|
||
assert.equal(result.ok, true);
|
||
const dataEvent = sentEvents.find(({ channel }) => channel === "netcatty:ai:stream:data");
|
||
assert.equal(dataEvent?.payload.data, '{"choices":[{"delta":{"content":"tail"}}]}');
|
||
} finally {
|
||
restore();
|
||
}
|
||
});
|
||
|
||
test("discover returns the 3-layer contract for an installed, authenticated agent", async (t) => {
|
||
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-discover-contract-"));
|
||
t.after(() => fs.rmSync(tempDir, { recursive: true, force: true }));
|
||
const claudePath = path.join(tempDir, process.platform === "win32" ? "claude.cmd" : "claude");
|
||
fs.writeFileSync(
|
||
claudePath,
|
||
process.platform === "win32" ? "@echo off\r\necho 1.2.3 (Claude Code)\r\n" : "#!/bin/sh\necho '1.2.3 (Claude Code)'\n",
|
||
{ mode: 0o755 },
|
||
);
|
||
|
||
const { bridge, restore } = loadBridgeWithMocks({
|
||
resolveCliFromPath: (cmd) => (cmd === "claude" ? claudePath : null),
|
||
isPlausibleCliVersionOutput: () => true,
|
||
probeClaudeAuth: () => ({ authenticated: true, authSource: "env" }),
|
||
});
|
||
const ipcMain = createIpcMainStub();
|
||
bridge.init({ sessions: new Map(), sftpClients: new Map(), electronModule: { app: { getPath: () => process.cwd() } } });
|
||
bridge.registerHandlers(ipcMain);
|
||
|
||
try {
|
||
const discover = ipcMain.handlers.get("netcatty:ai:agents:discover");
|
||
assert.equal(typeof discover, "function");
|
||
const agents = await discover({ sender: { id: 1 } });
|
||
const claude = agents.find((agent) => agent.command === "claude");
|
||
assert.ok(claude);
|
||
assert.equal(claude.sdkBackend, "claude");
|
||
assert.equal(claude.binPath, claudePath);
|
||
assert.equal(claude.path, claudePath);
|
||
assert.equal(claude.installed, true);
|
||
assert.equal(claude.available, true);
|
||
assert.equal(claude.authenticated, true);
|
||
assert.equal(claude.authSource, "env");
|
||
} finally {
|
||
restore();
|
||
}
|
||
});
|
||
|
||
test("resolve-cli does not fall back to PATH when a custom path is invalid", async () => {
|
||
const { bridge, restore } = loadBridgeWithMocks({
|
||
normalizeCliPathForPlatform: () => null,
|
||
resolveCliFromPath: (command) => (command === "codex" ? "/usr/local/bin/codex" : null),
|
||
});
|
||
const ipcMain = createIpcMainStub();
|
||
|
||
bridge.init({
|
||
sessions: new Map(),
|
||
sftpClients: new Map(),
|
||
electronModule: { app: { getPath: () => process.cwd() } },
|
||
});
|
||
bridge.registerHandlers(ipcMain);
|
||
|
||
try {
|
||
const resolveCli = ipcMain.handlers.get("netcatty:ai:resolve-cli");
|
||
const result = await resolveCli({ sender: { id: 1 } }, {
|
||
command: "codex",
|
||
customPath: "/missing/codex",
|
||
refreshShellEnv: true,
|
||
});
|
||
|
||
assert.equal(result.path, null);
|
||
assert.equal(result.available, false);
|
||
assert.equal(result.installed, false);
|
||
} finally {
|
||
restore();
|
||
}
|
||
});
|
||
|
||
test("codex login does not reuse an active session from a different resolved path", async () => {
|
||
const { bridge, restore } = loadBridgeWithMocks({
|
||
resolveCliFromPathAsync: (command) => (command === "codex" ? "/usr/bin/codex" : null),
|
||
getActiveCodexLoginSession: () => ({
|
||
id: "codex_login_custom",
|
||
state: "running",
|
||
process: { killed: false },
|
||
codexPath: "/custom/codex",
|
||
}),
|
||
});
|
||
const ipcMain = createIpcMainStub();
|
||
|
||
bridge.init({
|
||
sessions: new Map(),
|
||
sftpClients: new Map(),
|
||
electronModule: { app: { getPath: () => process.cwd() } },
|
||
});
|
||
bridge.registerHandlers(ipcMain);
|
||
|
||
try {
|
||
const startLogin = ipcMain.handlers.get("netcatty:ai:codex:start-login");
|
||
const result = await startLogin({ sender: { id: 1 } }, {});
|
||
|
||
assert.equal(result.ok, false);
|
||
assert.match(result.error, /different CLI path/);
|
||
} finally {
|
||
restore();
|
||
}
|
||
});
|
||
|
||
test("codex integration keeps ChatGPT connected when the SDK validation probe fails", async (t) => {
|
||
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-codex-integration-"));
|
||
t.after(() => {
|
||
fs.rmSync(tempDir, { recursive: true, force: true });
|
||
});
|
||
|
||
const codexPath = path.join(tempDir, "codex");
|
||
fs.writeFileSync(
|
||
codexPath,
|
||
`#!${process.execPath}\nconsole.log('Logged in using ChatGPT');\n`,
|
||
{ mode: 0o755 },
|
||
);
|
||
|
||
const { bridge, restore } = loadBridgeWithMocks({
|
||
normalizeCliPathForPlatform: (value) => value,
|
||
});
|
||
const ipcMain = createIpcMainStub();
|
||
|
||
bridge.init({
|
||
sessions: new Map(),
|
||
sftpClients: new Map(),
|
||
electronModule: { app: { getPath: () => process.cwd() } },
|
||
});
|
||
bridge.registerHandlers(ipcMain);
|
||
|
||
try {
|
||
const handler = ipcMain.handlers.get("netcatty:ai:codex:get-integration");
|
||
assert.equal(typeof handler, "function");
|
||
|
||
const result = await handler({ sender: { id: 1 } }, {
|
||
codexPath,
|
||
validateChatGptAuth: true,
|
||
});
|
||
|
||
assert.equal(result.state, "connected_chatgpt", JSON.stringify(result));
|
||
assert.equal(result.isConnected, true);
|
||
assert.match(result.rawOutput, /Logged in using ChatGPT/);
|
||
assert.match(result.rawOutput, /ChatGPT auth validation failed:/);
|
||
} finally {
|
||
restore();
|
||
}
|
||
});
|
||
|
||
test("resolve-cli probes Windows cmd paths with spaces", { skip: process.platform !== "win32" }, async (t) => {
|
||
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty codex resolve "));
|
||
t.after(() => {
|
||
fs.rmSync(tempDir, { recursive: true, force: true });
|
||
});
|
||
|
||
const codexPath = path.join(tempDir, "codex.cmd");
|
||
fs.writeFileSync(
|
||
codexPath,
|
||
"@echo off\r\necho codex-cli 1.2.3\r\n",
|
||
"utf8",
|
||
);
|
||
|
||
const { bridge, restore } = loadBridgeWithMocks({
|
||
prepareCommandForSpawn,
|
||
resolveCliFromPath: (command) => (command === "codex" ? codexPath : null),
|
||
});
|
||
const ipcMain = createIpcMainStub();
|
||
|
||
bridge.init({
|
||
sessions: new Map(),
|
||
sftpClients: new Map(),
|
||
electronModule: { app: { getPath: () => process.cwd() } },
|
||
});
|
||
bridge.registerHandlers(ipcMain);
|
||
|
||
try {
|
||
const resolveHandler = ipcMain.handlers.get("netcatty:ai:resolve-cli");
|
||
assert.equal(typeof resolveHandler, "function");
|
||
|
||
const result = await resolveHandler({ sender: { id: 1 } }, { command: "codex", customPath: "" });
|
||
|
||
assert.deepEqual(result, {
|
||
path: codexPath,
|
||
binPath: codexPath,
|
||
version: "codex-cli 1.2.3",
|
||
available: true,
|
||
installed: true,
|
||
});
|
||
} finally {
|
||
restore();
|
||
}
|
||
});
|
||
|
||
test("resolve-cli probes Windows Claude cmd paths with spaces", { skip: process.platform !== "win32" }, async (t) => {
|
||
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty claude resolve "));
|
||
t.after(() => {
|
||
fs.rmSync(tempDir, { recursive: true, force: true });
|
||
});
|
||
|
||
const claudePath = path.join(tempDir, "claude.cmd");
|
||
fs.writeFileSync(
|
||
claudePath,
|
||
"@echo off\r\necho 2.1.123 (Claude Code)\r\n",
|
||
"utf8",
|
||
);
|
||
|
||
const { bridge, restore } = loadBridgeWithMocks({
|
||
prepareCommandForSpawn,
|
||
resolveCliFromPath: (command) => (command === "claude" ? claudePath : null),
|
||
});
|
||
const ipcMain = createIpcMainStub();
|
||
|
||
bridge.init({
|
||
sessions: new Map(),
|
||
sftpClients: new Map(),
|
||
electronModule: { app: { getPath: () => process.cwd() } },
|
||
});
|
||
bridge.registerHandlers(ipcMain);
|
||
|
||
try {
|
||
const resolveHandler = ipcMain.handlers.get("netcatty:ai:resolve-cli");
|
||
assert.equal(typeof resolveHandler, "function");
|
||
|
||
const result = await resolveHandler({ sender: { id: 1 } }, { command: "claude", customPath: "" });
|
||
|
||
assert.deepEqual(result, {
|
||
path: claudePath,
|
||
binPath: claudePath,
|
||
version: "2.1.123 (Claude Code)",
|
||
available: true,
|
||
installed: true,
|
||
});
|
||
} finally {
|
||
restore();
|
||
}
|
||
});
|
||
|
||
test("resolve-cli probes Windows Claude exe paths with spaces", { skip: process.platform !== "win32" }, async (t) => {
|
||
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty claude exe resolve "));
|
||
t.after(() => {
|
||
fs.rmSync(tempDir, { recursive: true, force: true });
|
||
});
|
||
|
||
const claudePath = path.join(tempDir, "claude.exe");
|
||
fs.copyFileSync(process.execPath, claudePath);
|
||
|
||
const { bridge, restore } = loadBridgeWithMocks({
|
||
prepareCommandForSpawn,
|
||
resolveCliFromPath: (command) => (command === "claude" ? claudePath : null),
|
||
});
|
||
const ipcMain = createIpcMainStub();
|
||
|
||
bridge.init({
|
||
sessions: new Map(),
|
||
sftpClients: new Map(),
|
||
electronModule: { app: { getPath: () => process.cwd() } },
|
||
});
|
||
bridge.registerHandlers(ipcMain);
|
||
|
||
try {
|
||
const resolveHandler = ipcMain.handlers.get("netcatty:ai:resolve-cli");
|
||
assert.equal(typeof resolveHandler, "function");
|
||
|
||
const result = await resolveHandler({ sender: { id: 1 } }, { command: "claude", customPath: "" });
|
||
|
||
assert.deepEqual(result, {
|
||
path: claudePath,
|
||
binPath: claudePath,
|
||
version: process.version,
|
||
available: true,
|
||
installed: true,
|
||
});
|
||
} finally {
|
||
restore();
|
||
}
|
||
});
|
||
|
||
test("resolve-cli reports Cursor Agent CLI on PATH as installed without CLI login", async () => {
|
||
const { bridge, restore } = loadBridgeWithMocks({
|
||
resolveCliFromPath: () => null,
|
||
probeCursorCliAuth: () => ({
|
||
authenticated: false,
|
||
authSource: null,
|
||
email: null,
|
||
binPath: "/usr/local/bin/cursor-agent",
|
||
}),
|
||
});
|
||
const ipcMain = createIpcMainStub();
|
||
bridge.init({ sessions: new Map(), sftpClients: new Map(), electronModule: { app: { getPath: () => process.cwd() } } });
|
||
bridge.registerHandlers(ipcMain);
|
||
|
||
try {
|
||
const resolveCli = ipcMain.handlers.get("netcatty:ai:resolve-cli");
|
||
const result = await resolveCli({ sender: { id: 1 } }, { command: "cursor", customPath: "" });
|
||
assert.equal(result.installed, true);
|
||
assert.equal(result.sdkInstalled, true);
|
||
assert.equal(result.cliBinPath, "/usr/local/bin/cursor-agent");
|
||
assert.equal(result.cliLoginOk, false);
|
||
assert.equal(result.available, false);
|
||
} finally {
|
||
restore();
|
||
}
|
||
});
|
||
|
||
test("resolve-cli reports Cursor SDK installed but unavailable without an API key", async () => {
|
||
const { bridge, restore } = loadBridgeWithMocks({
|
||
resolveCliFromPath: () => null,
|
||
});
|
||
const ipcMain = createIpcMainStub();
|
||
bridge.init({ sessions: new Map(), sftpClients: new Map(), electronModule: { app: { getPath: () => process.cwd() } } });
|
||
bridge.registerHandlers(ipcMain);
|
||
|
||
try {
|
||
const resolveCli = ipcMain.handlers.get("netcatty:ai:resolve-cli");
|
||
const result = await resolveCli({ sender: { id: 1 } }, { command: "cursor", customPath: "" });
|
||
assert.deepEqual(result, {
|
||
path: "cursor",
|
||
binPath: "cursor",
|
||
version: "Cursor SDK",
|
||
available: false,
|
||
installed: false,
|
||
authenticated: false,
|
||
authSource: null,
|
||
cliEmail: null,
|
||
cliBinPath: null,
|
||
cliLoginOk: false,
|
||
apiKeyOk: false,
|
||
sdkInstalled: true,
|
||
});
|
||
} finally {
|
||
restore();
|
||
}
|
||
});
|
||
|
||
test("resolve-cli separates Cursor SDK installation from API key availability", async () => {
|
||
const { bridge, restore } = loadBridgeWithMocks({
|
||
normalizeCliPathForPlatform: () => "/Applications/Cursor.app/Contents/MacOS/Cursor",
|
||
resolveCliFromPath: () => null,
|
||
});
|
||
const ipcMain = createIpcMainStub();
|
||
bridge.init({ sessions: new Map(), sftpClients: new Map(), electronModule: { app: { getPath: () => process.cwd() } } });
|
||
bridge.registerHandlers(ipcMain);
|
||
|
||
try {
|
||
const resolveCli = ipcMain.handlers.get("netcatty:ai:resolve-cli");
|
||
const result = await resolveCli(
|
||
{ sender: { id: 1 } },
|
||
{ command: "cursor", customPath: "/Applications/Cursor.app/Contents/MacOS/Cursor" },
|
||
);
|
||
assert.deepEqual(result, {
|
||
path: "cursor",
|
||
binPath: "cursor",
|
||
version: "Cursor SDK",
|
||
available: false,
|
||
installed: false,
|
||
authenticated: false,
|
||
authSource: null,
|
||
cliEmail: null,
|
||
cliBinPath: null,
|
||
cliLoginOk: false,
|
||
apiKeyOk: false,
|
||
sdkInstalled: true,
|
||
});
|
||
} finally {
|
||
restore();
|
||
}
|
||
});
|
||
|
||
test("resolve-cli ignores custom Cursor paths and stores the SDK sentinel path", async () => {
|
||
const { bridge, restore } = loadBridgeWithMocks({
|
||
normalizeCliPathForPlatform: () => "/tmp/not-cursor",
|
||
resolveCliFromPath: () => null,
|
||
shellEnv: { CURSOR_API_KEY: "cur-key" },
|
||
});
|
||
const ipcMain = createIpcMainStub();
|
||
bridge.init({ sessions: new Map(), sftpClients: new Map(), electronModule: { app: { getPath: () => process.cwd() } } });
|
||
bridge.registerHandlers(ipcMain);
|
||
|
||
try {
|
||
const resolveCli = ipcMain.handlers.get("netcatty:ai:resolve-cli");
|
||
const result = await resolveCli(
|
||
{ sender: { id: 1 } },
|
||
{ command: "cursor", customPath: "/tmp/not-cursor" },
|
||
);
|
||
assert.deepEqual(result, {
|
||
path: "cursor",
|
||
binPath: "cursor",
|
||
version: "Cursor SDK",
|
||
available: true,
|
||
installed: false,
|
||
authenticated: true,
|
||
authSource: "CURSOR_API_KEY",
|
||
cliEmail: null,
|
||
cliBinPath: null,
|
||
cliLoginOk: false,
|
||
apiKeyOk: true,
|
||
sdkInstalled: true,
|
||
});
|
||
} finally {
|
||
restore();
|
||
}
|
||
});
|
||
|
||
test("resolve-cli exposes Cursor SDK support when installed and authenticated", async () => {
|
||
const { bridge, restore } = loadBridgeWithMocks({
|
||
resolveCliFromPath: () => null,
|
||
shellEnv: { CURSOR_API_KEY: "cur-key" },
|
||
});
|
||
const ipcMain = createIpcMainStub();
|
||
bridge.init({ sessions: new Map(), sftpClients: new Map(), electronModule: { app: { getPath: () => process.cwd() } } });
|
||
bridge.registerHandlers(ipcMain);
|
||
|
||
try {
|
||
const resolveCli = ipcMain.handlers.get("netcatty:ai:resolve-cli");
|
||
const result = await resolveCli({ sender: { id: 1 } }, { command: "cursor", customPath: "" });
|
||
assert.deepEqual(result, {
|
||
path: "cursor",
|
||
binPath: "cursor",
|
||
version: "Cursor SDK",
|
||
available: true,
|
||
installed: false,
|
||
authenticated: true,
|
||
authSource: "CURSOR_API_KEY",
|
||
cliEmail: null,
|
||
cliBinPath: null,
|
||
cliLoginOk: false,
|
||
apiKeyOk: true,
|
||
sdkInstalled: true,
|
||
});
|
||
} finally {
|
||
restore();
|
||
}
|
||
});
|
||
|
||
test("resolve-cli exposes Cursor SDK support when API key is saved in settings", async () => {
|
||
const { bridge, restore } = loadBridgeWithMocks({
|
||
resolveCliFromPath: () => "/usr/local/bin/cursor",
|
||
});
|
||
const ipcMain = createIpcMainStub();
|
||
bridge.init({ sessions: new Map(), sftpClients: new Map(), electronModule: { app: { getPath: () => process.cwd() } } });
|
||
bridge.registerHandlers(ipcMain);
|
||
|
||
try {
|
||
const resolveCli = ipcMain.handlers.get("netcatty:ai:resolve-cli");
|
||
const result = await resolveCli(
|
||
{ sender: { id: 1 } },
|
||
{ command: "cursor", customPath: "", apiKeyPresent: true },
|
||
);
|
||
assert.deepEqual(result, {
|
||
path: "/usr/local/bin/cursor",
|
||
binPath: "/usr/local/bin/cursor",
|
||
version: "Cursor SDK",
|
||
available: true,
|
||
installed: false,
|
||
authenticated: true,
|
||
authSource: "settings",
|
||
cliEmail: null,
|
||
cliBinPath: null,
|
||
cliLoginOk: false,
|
||
apiKeyOk: true,
|
||
sdkInstalled: true,
|
||
});
|
||
} finally {
|
||
restore();
|
||
}
|
||
});
|
||
|
||
test("resolve-cli reports settings as Cursor auth source when settings and env keys both exist", async () => {
|
||
const { bridge, restore } = loadBridgeWithMocks({
|
||
resolveCliFromPath: () => "/usr/local/bin/cursor",
|
||
shellEnv: { CURSOR_API_KEY: "env-key" },
|
||
});
|
||
const ipcMain = createIpcMainStub();
|
||
bridge.init({ sessions: new Map(), sftpClients: new Map(), electronModule: { app: { getPath: () => process.cwd() } } });
|
||
bridge.registerHandlers(ipcMain);
|
||
|
||
try {
|
||
const resolveCli = ipcMain.handlers.get("netcatty:ai:resolve-cli");
|
||
const result = await resolveCli(
|
||
{ sender: { id: 1 } },
|
||
{ command: "cursor", customPath: "", apiKeyPresent: true },
|
||
);
|
||
|
||
assert.equal(result.available, true);
|
||
assert.equal(result.authenticated, true);
|
||
assert.equal(result.authSource, "settings");
|
||
} finally {
|
||
restore();
|
||
}
|
||
});
|
||
|
||
test("resolve-cli can refresh shell env before resolving Cursor", async () => {
|
||
let refreshed = false;
|
||
const { bridge, restore } = loadBridgeWithMocks({
|
||
resolveCliFromPath: () => null,
|
||
shellEnv: { CURSOR_API_KEY: "cur-key" },
|
||
invalidateShellEnvCache: () => { refreshed = true; },
|
||
});
|
||
const ipcMain = createIpcMainStub();
|
||
bridge.init({ sessions: new Map(), sftpClients: new Map(), electronModule: { app: { getPath: () => process.cwd() } } });
|
||
bridge.registerHandlers(ipcMain);
|
||
|
||
try {
|
||
const resolveCli = ipcMain.handlers.get("netcatty:ai:resolve-cli");
|
||
const result = await resolveCli(
|
||
{ sender: { id: 1 } },
|
||
{ command: "cursor", customPath: "", refreshShellEnv: true },
|
||
);
|
||
|
||
assert.equal(refreshed, true);
|
||
assert.equal(result.available, true);
|
||
} finally {
|
||
restore();
|
||
}
|
||
});
|
||
|
||
test("discover exposes Cursor when CLI login succeeds without API key", async () => {
|
||
const { bridge, restore } = loadBridgeWithMocks({
|
||
resolveCliFromPath: () => null,
|
||
probeCursorCliAuth: () => ({
|
||
authenticated: true,
|
||
authSource: "cli-login",
|
||
email: "user@example.com",
|
||
binPath: "/Users/me/.local/bin/agent",
|
||
}),
|
||
});
|
||
const ipcMain = createIpcMainStub();
|
||
bridge.init({ sessions: new Map(), sftpClients: new Map(), electronModule: { app: { getPath: () => process.cwd() } } });
|
||
bridge.registerHandlers(ipcMain);
|
||
|
||
try {
|
||
const discover = ipcMain.handlers.get("netcatty:ai:agents:discover");
|
||
const agents = await discover({ sender: { id: 1 } }, {});
|
||
const cursor = agents.find((agent) => agent.command === "cursor");
|
||
|
||
assert.equal(cursor?.available, true);
|
||
assert.equal(cursor?.installed, true);
|
||
assert.equal(cursor?.authenticated, true);
|
||
assert.equal(cursor?.authSource, "cli-login");
|
||
assert.equal(cursor?.path, "/Users/me/.local/bin/agent");
|
||
assert.equal(cursor?.cliEmail, "user@example.com");
|
||
} finally {
|
||
restore();
|
||
}
|
||
});
|
||
|
||
test("discover exposes Cursor SDK support when API key is saved in settings", async () => {
|
||
const { bridge, restore } = loadBridgeWithMocks({
|
||
resolveCliFromPath: () => null,
|
||
});
|
||
const ipcMain = createIpcMainStub();
|
||
bridge.init({ sessions: new Map(), sftpClients: new Map(), electronModule: { app: { getPath: () => process.cwd() } } });
|
||
bridge.registerHandlers(ipcMain);
|
||
|
||
try {
|
||
const discover = ipcMain.handlers.get("netcatty:ai:agents:discover");
|
||
const agents = await discover({ sender: { id: 1 } }, { apiKeyPresent: true });
|
||
const cursor = agents.find((agent) => agent.command === "cursor");
|
||
|
||
assert.equal(cursor?.path, "cursor");
|
||
assert.equal(cursor?.available, true);
|
||
assert.equal(cursor?.installed, false);
|
||
assert.equal(cursor?.sdkInstalled, true);
|
||
assert.equal(cursor?.authenticated, true);
|
||
assert.equal(cursor?.authSource, "settings");
|
||
} finally {
|
||
restore();
|
||
}
|
||
});
|
||
|
||
test("resolve-cli exposes Cursor CLI login without API key", async () => {
|
||
const { bridge, restore } = loadBridgeWithMocks({
|
||
resolveCliFromPath: () => null,
|
||
probeCursorCliAuth: () => ({
|
||
authenticated: true,
|
||
authSource: "cli-login",
|
||
email: "user@example.com",
|
||
binPath: "/Users/me/.local/bin/agent",
|
||
}),
|
||
});
|
||
const ipcMain = createIpcMainStub();
|
||
bridge.init({ sessions: new Map(), sftpClients: new Map(), electronModule: { app: { getPath: () => process.cwd() } } });
|
||
bridge.registerHandlers(ipcMain);
|
||
|
||
try {
|
||
const resolveCli = ipcMain.handlers.get("netcatty:ai:resolve-cli");
|
||
const result = await resolveCli({ sender: { id: 1 } }, { command: "cursor", customPath: "" });
|
||
assert.equal(result.available, true);
|
||
assert.equal(result.installed, true);
|
||
assert.equal(result.authenticated, true);
|
||
assert.equal(result.authSource, "cli-login");
|
||
assert.equal(result.path, "/Users/me/.local/bin/agent");
|
||
assert.equal(result.cliEmail, "user@example.com");
|
||
} finally {
|
||
restore();
|
||
}
|
||
});
|
||
|
||
test("discover can refresh shell env before scanning Cursor", async () => {
|
||
let refreshed = false;
|
||
const { bridge, restore } = loadBridgeWithMocks({
|
||
resolveCliFromPath: () => null,
|
||
shellEnv: () => (refreshed ? { CURSOR_API_KEY: "cur-key" } : {}),
|
||
invalidateShellEnvCache: () => { refreshed = true; },
|
||
});
|
||
const ipcMain = createIpcMainStub();
|
||
bridge.init({ sessions: new Map(), sftpClients: new Map(), electronModule: { app: { getPath: () => process.cwd() } } });
|
||
bridge.registerHandlers(ipcMain);
|
||
|
||
try {
|
||
const discover = ipcMain.handlers.get("netcatty:ai:agents:discover");
|
||
const agents = await discover({ sender: { id: 1 } }, { refreshShellEnv: true });
|
||
const cursor = agents.find((agent) => agent.command === "cursor");
|
||
|
||
assert.equal(refreshed, true);
|
||
assert.equal(cursor?.path, "cursor");
|
||
assert.equal(cursor?.available, true);
|
||
} finally {
|
||
restore();
|
||
}
|
||
});
|
||
|
||
/** Boot the bridge with a local HTTP server and capture the request it receives. */
|
||
async function withCapturingServer(t, options, respond) {
|
||
const received = {};
|
||
let handleStreamEvent = () => {};
|
||
const { bridge, restore } = loadBridgeWithMocks({
|
||
safeSend: (_sender, channel, payload) => handleStreamEvent(channel, payload),
|
||
...options,
|
||
});
|
||
const ipcMain = createIpcMainStub();
|
||
const server = http.createServer((req, res) => {
|
||
const chunks = [];
|
||
req.on("data", (chunk) => chunks.push(chunk));
|
||
req.on("end", () => {
|
||
received.headers = req.headers;
|
||
received.body = Buffer.concat(chunks);
|
||
respond(res);
|
||
});
|
||
});
|
||
|
||
await new Promise((resolve, reject) => {
|
||
server.once("error", reject);
|
||
server.listen(0, "127.0.0.1", resolve);
|
||
});
|
||
t.after(() => new Promise((resolve) => server.close(resolve)));
|
||
|
||
bridge.init({
|
||
sessions: new Map(),
|
||
sftpClients: new Map(),
|
||
electronModule: { app: { getPath: () => process.cwd() } },
|
||
});
|
||
bridge.registerHandlers(ipcMain);
|
||
|
||
const address = server.address();
|
||
assert.ok(address && typeof address === "object");
|
||
const baseURL = `http://127.0.0.1:${address.port}`;
|
||
const sender = { id: 1 };
|
||
await ipcMain.handlers.get("netcatty:ai:sync-providers")(
|
||
{ sender },
|
||
{ providers: [{ id: "content-length", baseURL }] },
|
||
);
|
||
|
||
return {
|
||
received,
|
||
baseURL,
|
||
sender,
|
||
ipcMain,
|
||
restore,
|
||
onStreamEvent: (fn) => {
|
||
handleStreamEvent = fn;
|
||
},
|
||
};
|
||
}
|
||
|
||
test("streaming AI requests send Content-Length instead of chunked encoding", { timeout: 5_000 }, async (t) => {
|
||
const ctx = await withCapturingServer(t, {}, (res) => {
|
||
res.writeHead(200, { "content-type": "text/event-stream" });
|
||
res.end('data: {"choices":[{"delta":{"content":"ok"}}]}\n\n');
|
||
});
|
||
|
||
try {
|
||
const body = JSON.stringify({
|
||
stream: true,
|
||
messages: [{ role: "user", content: "hi" }],
|
||
});
|
||
|
||
const streamFinished = new Promise((resolve, reject) => {
|
||
ctx.onStreamEvent((channel, payload) => {
|
||
if (channel === "netcatty:ai:stream:end") resolve();
|
||
else if (channel === "netcatty:ai:stream:error") reject(new Error(payload.error));
|
||
});
|
||
});
|
||
|
||
const result = await ctx.ipcMain.handlers.get("netcatty:ai:chat:stream")(
|
||
{ sender: ctx.sender },
|
||
{
|
||
requestId: "content-length-stream",
|
||
url: `${ctx.baseURL}/v1/messages`,
|
||
headers: { "content-type": "application/json" },
|
||
body,
|
||
providerId: "content-length",
|
||
},
|
||
);
|
||
await streamFinished;
|
||
|
||
assert.equal(result.ok, true);
|
||
assert.equal(ctx.received.headers["content-length"], String(Buffer.byteLength(body)));
|
||
assert.equal(ctx.received.headers["transfer-encoding"], undefined);
|
||
assert.equal(ctx.received.body.toString("utf8"), body);
|
||
} finally {
|
||
ctx.restore();
|
||
}
|
||
});
|
||
|
||
test("Content-Length for AI request bodies counts bytes, not UTF-16 code units", { timeout: 5_000 }, async (t) => {
|
||
const ctx = await withCapturingServer(t, {}, (res) => {
|
||
res.writeHead(200, { "content-type": "text/event-stream" });
|
||
res.end('data: {"choices":[{"delta":{"content":"ok"}}]}\n\n');
|
||
});
|
||
|
||
try {
|
||
// A realistic system prompt: multi-byte characters make the UTF-16 length
|
||
// shorter than the UTF-8 byte length, so `body.length` would truncate.
|
||
const body = JSON.stringify({
|
||
stream: true,
|
||
system: "你是 Catty Agent,一个内置于 netcatty 的终端自动化助手。请检查磁盘使用率。",
|
||
messages: [{ role: "user", content: "检查系统状态" }],
|
||
});
|
||
assert.notEqual(body.length, Buffer.byteLength(body));
|
||
|
||
const streamFinished = new Promise((resolve, reject) => {
|
||
ctx.onStreamEvent((channel, payload) => {
|
||
if (channel === "netcatty:ai:stream:end") resolve();
|
||
else if (channel === "netcatty:ai:stream:error") reject(new Error(payload.error));
|
||
});
|
||
});
|
||
|
||
const result = await ctx.ipcMain.handlers.get("netcatty:ai:chat:stream")(
|
||
{ sender: ctx.sender },
|
||
{
|
||
requestId: "content-length-multibyte",
|
||
url: `${ctx.baseURL}/v1/messages`,
|
||
headers: { "content-type": "application/json" },
|
||
body,
|
||
providerId: "content-length",
|
||
},
|
||
);
|
||
await streamFinished;
|
||
|
||
assert.equal(result.ok, true);
|
||
assert.equal(ctx.received.headers["content-length"], String(Buffer.byteLength(body)));
|
||
assert.equal(ctx.received.body.toString("utf8"), body);
|
||
} finally {
|
||
ctx.restore();
|
||
}
|
||
});
|
||
|
||
test("non-streaming AI requests send Content-Length instead of chunked encoding", { timeout: 5_000 }, async (t) => {
|
||
const ctx = await withCapturingServer(t, {}, (res) => {
|
||
res.writeHead(200, { "content-type": "application/json" });
|
||
res.end('{"data":[]}');
|
||
});
|
||
|
||
try {
|
||
const body = JSON.stringify({ model: "test-model", input: "hi" });
|
||
|
||
const result = await ctx.ipcMain.handlers.get("netcatty:ai:fetch")(
|
||
{ sender: ctx.sender },
|
||
{
|
||
url: `${ctx.baseURL}/v1/messages`,
|
||
method: "POST",
|
||
headers: { "content-type": "application/json" },
|
||
body,
|
||
providerId: "content-length",
|
||
},
|
||
);
|
||
|
||
assert.equal(result.ok, true);
|
||
assert.equal(ctx.received.headers["content-length"], String(Buffer.byteLength(body)));
|
||
assert.equal(ctx.received.headers["transfer-encoding"], undefined);
|
||
assert.equal(ctx.received.body.toString("utf8"), body);
|
||
} finally {
|
||
ctx.restore();
|
||
}
|
||
});
|