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
691 lines
20 KiB
JavaScript
691 lines
20 KiB
JavaScript
"use strict";
|
|
|
|
const assert = require("node:assert/strict");
|
|
const test = require("node:test");
|
|
|
|
function loadFreshBridge() {
|
|
const bridgePath = require.resolve("./mcpServerBridge.cjs");
|
|
delete require.cache[bridgePath];
|
|
return require("./mcpServerBridge.cjs");
|
|
}
|
|
|
|
test("MCP/Catty capability context uses scoped metadata when terminal sessions live in worker", async () => {
|
|
const requests = [];
|
|
const bridge = loadFreshBridge();
|
|
bridge.init({
|
|
sessions: new Map(),
|
|
electronModule: null,
|
|
terminalWorkerManager: {
|
|
request(channel, payload, options) {
|
|
requests.push({ channel, payload, options });
|
|
if (channel === "netcatty:portforward:list") {
|
|
return Promise.resolve([{ tunnelId: "worker-pf-1", status: "active" }]);
|
|
}
|
|
throw new Error(`unexpected worker request: ${channel}`);
|
|
},
|
|
},
|
|
});
|
|
bridge.setPermissionMode("auto");
|
|
bridge.updateSessionMetadata([
|
|
{
|
|
sessionId: "ssh-1",
|
|
hostname: "host.example",
|
|
label: "Prod",
|
|
username: "root",
|
|
protocol: "ssh",
|
|
shellType: "bash",
|
|
connected: true,
|
|
},
|
|
], "chat-1");
|
|
|
|
const result = await bridge.dispatchBuiltinRpc("netcatty/getContext", {
|
|
chatSessionId: "chat-1",
|
|
});
|
|
|
|
assert.equal(result.hostCount, 1);
|
|
assert.equal(result.tools.terminal.execute, "terminal_execute");
|
|
assert.equal(result.tools.terminal.start, "terminal_start");
|
|
assert.match(result.description, /terminal_execute/);
|
|
assert.deepEqual(result.activePortForwardTunnels, [
|
|
{ tunnelId: "worker-pf-1", status: "active" },
|
|
]);
|
|
assert.deepEqual(requests, [{
|
|
channel: "netcatty:portforward:list",
|
|
payload: {},
|
|
options: {},
|
|
}]);
|
|
assert.deepEqual(result.hosts[0], {
|
|
sessionId: "ssh-1",
|
|
hostname: "host.example",
|
|
label: "Prod",
|
|
os: "",
|
|
username: "root",
|
|
protocol: "ssh",
|
|
shellType: "bash",
|
|
deviceType: "",
|
|
connected: true,
|
|
hostId: "",
|
|
hostChain: [],
|
|
activePortForwards: [],
|
|
});
|
|
});
|
|
|
|
test("MCP/Catty terminal_execute proxies to worker when terminal sessions live in worker", async () => {
|
|
const requests = [];
|
|
const bridge = loadFreshBridge();
|
|
bridge.init({
|
|
sessions: new Map(),
|
|
electronModule: null,
|
|
terminalWorkerManager: {
|
|
request(channel, payload, options) {
|
|
requests.push({ channel, payload, options });
|
|
return Promise.resolve({ ok: true, stdout: "ok\n", stderr: "", exitCode: 0 });
|
|
},
|
|
},
|
|
});
|
|
bridge.setPermissionMode("auto");
|
|
bridge.setCommandBlocklist([]);
|
|
bridge.setCommandTimeout(23);
|
|
bridge.updateSessionMetadata([
|
|
{
|
|
sessionId: "ssh-1",
|
|
hostname: "host.example",
|
|
protocol: "ssh",
|
|
connected: true,
|
|
},
|
|
], "chat-1");
|
|
|
|
const result = await bridge.dispatchBuiltinRpc("netcatty/exec", {
|
|
sessionId: "ssh-1",
|
|
command: "pwd",
|
|
chatSessionId: "chat-1",
|
|
});
|
|
|
|
assert.deepEqual(result, { ok: true, stdout: "ok\n", stderr: "", exitCode: 0 });
|
|
assert.deepEqual(requests, [
|
|
{
|
|
channel: "netcatty:ai:exec",
|
|
payload: {
|
|
sessionId: "ssh-1",
|
|
command: "pwd",
|
|
chatSessionId: "chat-1",
|
|
commandTimeoutMs: 23000,
|
|
sessionMeta: {
|
|
hostname: "host.example",
|
|
label: "",
|
|
os: "",
|
|
username: "",
|
|
protocol: "ssh",
|
|
shellType: "",
|
|
deviceType: "",
|
|
connected: true,
|
|
hostId: "",
|
|
hostChain: [],
|
|
activePortForwards: [],
|
|
},
|
|
enforceWallTimeout: true,
|
|
commandBlocklist: [],
|
|
},
|
|
options: {},
|
|
},
|
|
]);
|
|
});
|
|
|
|
test("approved worker command is rejected when its session scope disappeared while waiting", async () => {
|
|
let approvalId = null;
|
|
let workerRequestCount = 0;
|
|
const bridge = loadFreshBridge();
|
|
bridge.init({
|
|
sessions: new Map(),
|
|
electronModule: null,
|
|
terminalWorkerManager: {
|
|
request() {
|
|
workerRequestCount += 1;
|
|
return Promise.resolve({ ok: true, stdout: "unexpected" });
|
|
},
|
|
},
|
|
});
|
|
bridge.setMainWindowGetter(() => ({
|
|
isDestroyed: () => false,
|
|
webContents: {
|
|
id: 1,
|
|
send(channel, payload) {
|
|
if (channel === "netcatty:ai:mcp:approval-request") approvalId = payload.approvalId;
|
|
},
|
|
},
|
|
}));
|
|
bridge.setPermissionMode("confirm");
|
|
bridge.setCommandBlocklist([]);
|
|
bridge.updateSessionMetadata([
|
|
{ sessionId: "ssh-approval", protocol: "ssh", connected: true },
|
|
], "chat-approval");
|
|
|
|
const pending = bridge.dispatchBuiltinRpc("netcatty/exec", {
|
|
sessionId: "ssh-approval",
|
|
command: "pwd",
|
|
chatSessionId: "chat-approval",
|
|
});
|
|
await new Promise((resolve) => setImmediate(resolve));
|
|
assert.ok(approvalId);
|
|
|
|
bridge.updateSessionMetadata([], "chat-approval");
|
|
bridge.resolveApprovalFromRenderer(approvalId, true);
|
|
const result = await pending;
|
|
|
|
assert.equal(result.ok, false);
|
|
assert.match(result.error, /scope/);
|
|
assert.equal(workerRequestCount, 0);
|
|
});
|
|
|
|
test("MCP/Catty SFTP tools proxy to worker when terminal sessions live in worker", async () => {
|
|
const requests = [];
|
|
const bridge = loadFreshBridge();
|
|
bridge.init({
|
|
sessions: new Map(),
|
|
electronModule: null,
|
|
terminalWorkerManager: {
|
|
request(channel, payload, options) {
|
|
requests.push({ channel, payload, options });
|
|
if (channel === "netcatty:sftp:openForSession") {
|
|
return Promise.resolve({ ok: true, sftpId: "worker-sftp-1" });
|
|
}
|
|
if (channel === "netcatty:sftp:list") {
|
|
return Promise.resolve([
|
|
{ name: "app.log", type: "file", size: "12 bytes" },
|
|
]);
|
|
}
|
|
if (channel === "netcatty:sftp:close") {
|
|
return Promise.resolve({ ok: true });
|
|
}
|
|
return Promise.reject(new Error(`unexpected worker request: ${channel}`));
|
|
},
|
|
},
|
|
});
|
|
bridge.setPermissionMode("auto");
|
|
bridge.setCommandTimeout(23);
|
|
bridge.updateSessionMetadata([
|
|
{
|
|
sessionId: "ssh-1",
|
|
hostname: "host.example",
|
|
protocol: "ssh",
|
|
connected: true,
|
|
},
|
|
], "chat-1");
|
|
|
|
const result = await bridge.dispatchBuiltinRpc("netcatty/sftp/list", {
|
|
sessionId: "ssh-1",
|
|
path: "/var/log",
|
|
chatSessionId: "chat-1",
|
|
});
|
|
|
|
assert.deepEqual(result, {
|
|
ok: true,
|
|
entries: [{ name: "app.log", type: "file", size: "12 bytes" }],
|
|
});
|
|
assert.deepEqual(requests, [
|
|
{
|
|
channel: "netcatty:sftp:openForSession",
|
|
payload: {
|
|
sessionId: "ssh-1",
|
|
encodingStateKey: "chat:chat-1:session:ssh-1",
|
|
timeoutMs: 23000,
|
|
},
|
|
options: {},
|
|
},
|
|
{
|
|
channel: "netcatty:sftp:list",
|
|
payload: {
|
|
sessionId: "ssh-1",
|
|
path: "/var/log",
|
|
chatSessionId: "chat-1",
|
|
sftpId: "worker-sftp-1",
|
|
timeoutMs: 23000,
|
|
},
|
|
options: {},
|
|
},
|
|
{
|
|
channel: "netcatty:sftp:close",
|
|
payload: {
|
|
sftpId: "worker-sftp-1",
|
|
encodingStateKey: "chat:chat-1:session:ssh-1",
|
|
},
|
|
options: {},
|
|
},
|
|
]);
|
|
});
|
|
|
|
test("worker SFTP cancellation waits for a pending open and closes its late handle", async () => {
|
|
let resolveOpen;
|
|
const openPromise = new Promise((resolve) => {
|
|
resolveOpen = resolve;
|
|
});
|
|
const requests = [];
|
|
const bridge = loadFreshBridge();
|
|
bridge.init({
|
|
sessions: new Map(),
|
|
electronModule: null,
|
|
terminalWorkerManager: {
|
|
request(channel, payload, options) {
|
|
requests.push({ channel, payload, options });
|
|
if (channel === "netcatty:sftp:openForSession") return openPromise;
|
|
if (channel === "netcatty:sftp:close") return Promise.resolve({ ok: true });
|
|
return Promise.reject(new Error(`unexpected worker request: ${channel}`));
|
|
},
|
|
},
|
|
});
|
|
bridge.setPermissionMode("auto");
|
|
bridge.setCommandTimeout(23);
|
|
bridge.updateSessionMetadata([
|
|
{ sessionId: "ssh-pending", hostname: "host.example", protocol: "ssh", connected: true },
|
|
], "chat-pending");
|
|
|
|
const operation = bridge.dispatchBuiltinRpc("netcatty/sftp/list", {
|
|
sessionId: "ssh-pending",
|
|
path: "/var/log",
|
|
chatSessionId: "chat-pending",
|
|
});
|
|
await new Promise((resolve) => setImmediate(resolve));
|
|
const cancellation = bridge.cancelSftpOpsForSession("chat-pending");
|
|
|
|
resolveOpen({ ok: true, sftpId: "worker-sftp-late" });
|
|
await cancellation;
|
|
await assert.rejects(operation, /Cancelled/);
|
|
|
|
assert.deepEqual(requests.map((entry) => entry.channel), [
|
|
"netcatty:sftp:openForSession",
|
|
"netcatty:sftp:close",
|
|
]);
|
|
assert.equal(requests[1].payload.sftpId, "worker-sftp-late");
|
|
});
|
|
|
|
test("worker SFTP cancellation stays bounded when open never responds", async () => {
|
|
const bridge = loadFreshBridge();
|
|
bridge.init({
|
|
sessions: new Map(),
|
|
electronModule: null,
|
|
terminalWorkerManager: {
|
|
request(channel) {
|
|
if (channel === "netcatty:sftp:openForSession") return new Promise(() => {});
|
|
return Promise.reject(new Error(`unexpected worker request: ${channel}`));
|
|
},
|
|
},
|
|
});
|
|
bridge.setPermissionMode("auto");
|
|
bridge.setCommandTimeout(0.01);
|
|
bridge.updateSessionMetadata([
|
|
{ sessionId: "ssh-stalled", hostname: "host.example", protocol: "ssh", connected: true },
|
|
], "chat-stalled");
|
|
|
|
const operation = bridge.dispatchBuiltinRpc("netcatty/sftp/list", {
|
|
sessionId: "ssh-stalled",
|
|
path: "/var/log",
|
|
chatSessionId: "chat-stalled",
|
|
});
|
|
await new Promise((resolve) => setImmediate(resolve));
|
|
const startedAt = Date.now();
|
|
const cancellation = bridge.cancelSftpOpsForSession("chat-stalled");
|
|
|
|
await assert.rejects(operation, /timed out/);
|
|
await cancellation;
|
|
assert.ok(Date.now() - startedAt < 2000, "cancellation should honor the bounded open timeout");
|
|
});
|
|
|
|
test("MCP/Catty terminal_start, poll, and stop proxy worker background jobs", async () => {
|
|
const requests = [];
|
|
const bridge = loadFreshBridge();
|
|
bridge.init({
|
|
sessions: new Map(),
|
|
electronModule: null,
|
|
terminalWorkerManager: {
|
|
request(channel, payload, options) {
|
|
requests.push({ channel, payload, options });
|
|
if (channel === "netcatty:ai:jobStart") {
|
|
return Promise.resolve({
|
|
ok: true,
|
|
jobId: "worker-job-1",
|
|
sessionId: payload.sessionId,
|
|
command: payload.command,
|
|
status: "running",
|
|
});
|
|
}
|
|
if (channel === "netcatty:ai:jobPoll") {
|
|
return Promise.resolve({
|
|
ok: true,
|
|
jobId: payload.jobId,
|
|
sessionId: "ssh-1",
|
|
command: "npm test",
|
|
status: "running",
|
|
completed: false,
|
|
output: "done\n",
|
|
nextOffset: 5,
|
|
});
|
|
}
|
|
if (channel === "netcatty:ai:jobStop") {
|
|
return Promise.resolve({
|
|
ok: true,
|
|
jobId: payload.jobId,
|
|
sessionId: "ssh-1",
|
|
status: "stopping",
|
|
completed: false,
|
|
});
|
|
}
|
|
return Promise.reject(new Error(`unexpected worker request: ${channel}`));
|
|
},
|
|
},
|
|
});
|
|
bridge.setPermissionMode("auto");
|
|
bridge.setCommandBlocklist([]);
|
|
bridge.setCommandTimeout(23);
|
|
bridge.updateSessionMetadata([
|
|
{
|
|
sessionId: "ssh-1",
|
|
hostname: "host.example",
|
|
protocol: "ssh",
|
|
shellType: "bash",
|
|
connected: true,
|
|
},
|
|
], "chat-1");
|
|
|
|
const started = await bridge.dispatchBuiltinRpc("netcatty/jobStart", {
|
|
sessionId: "ssh-1",
|
|
command: "npm test",
|
|
chatSessionId: "chat-1",
|
|
});
|
|
const polled = await bridge.dispatchBuiltinRpc("netcatty/jobPoll", {
|
|
jobId: "worker-job-1",
|
|
offset: 0,
|
|
chatSessionId: "chat-1",
|
|
});
|
|
const stopped = await bridge.dispatchBuiltinRpc("netcatty/jobStop", {
|
|
jobId: "worker-job-1",
|
|
chatSessionId: "chat-1",
|
|
});
|
|
const polledAfterStop = await bridge.dispatchBuiltinRpc("netcatty/jobPoll", {
|
|
jobId: "worker-job-1",
|
|
offset: 5,
|
|
chatSessionId: "chat-1",
|
|
});
|
|
|
|
assert.equal(started.ok, true);
|
|
assert.equal(polled.output, "done\n");
|
|
assert.equal(stopped.status, "stopping");
|
|
assert.equal(polledAfterStop.ok, true);
|
|
assert.deepEqual(requests.map((entry) => entry.channel), [
|
|
"netcatty:ai:jobStart",
|
|
"netcatty:ai:jobPoll",
|
|
"netcatty:ai:jobStop",
|
|
"netcatty:ai:jobPoll",
|
|
]);
|
|
assert.deepEqual(requests[0].payload, {
|
|
sessionId: "ssh-1",
|
|
command: "npm test",
|
|
chatSessionId: "chat-1",
|
|
commandTimeoutMs: 23000,
|
|
sessionMeta: {
|
|
hostname: "host.example",
|
|
label: "",
|
|
os: "",
|
|
username: "",
|
|
protocol: "ssh",
|
|
shellType: "bash",
|
|
deviceType: "",
|
|
connected: true,
|
|
hostId: "",
|
|
hostChain: [],
|
|
activePortForwards: [],
|
|
},
|
|
commandBlocklist: [],
|
|
});
|
|
});
|
|
|
|
test("idle cleanup drops completed worker jobs even when the agent never polled them", async () => {
|
|
const requests = [];
|
|
const bridge = loadFreshBridge();
|
|
bridge.init({
|
|
sessions: new Map(),
|
|
electronModule: null,
|
|
terminalWorkerManager: {
|
|
request(channel, payload) {
|
|
requests.push({ channel, payload });
|
|
if (channel === "netcatty:ai:jobStart") {
|
|
return Promise.resolve({
|
|
ok: true,
|
|
jobId: "worker-job-unpolled",
|
|
sessionId: payload.sessionId,
|
|
status: "running",
|
|
});
|
|
}
|
|
if (channel === "netcatty:ai:jobPoll") {
|
|
return Promise.resolve({
|
|
ok: true,
|
|
jobId: payload.jobId,
|
|
sessionId: payload.sessionId,
|
|
status: "completed",
|
|
completed: true,
|
|
});
|
|
}
|
|
return Promise.reject(new Error(`unexpected worker request: ${channel}`));
|
|
},
|
|
},
|
|
});
|
|
bridge.setPermissionMode("auto");
|
|
bridge.setCommandBlocklist([]);
|
|
bridge.updateSessionMetadata([
|
|
{ sessionId: "ssh-unpolled", protocol: "ssh", connected: true },
|
|
], "chat-unpolled");
|
|
|
|
const started = await bridge.dispatchBuiltinRpc("netcatty/jobStart", {
|
|
sessionId: "ssh-unpolled",
|
|
command: "sleep 1",
|
|
chatSessionId: "chat-unpolled",
|
|
});
|
|
assert.equal(started.ok, true);
|
|
|
|
assert.equal(await bridge.hasActiveWorkerJobForTerminalSession("ssh-unpolled"), false);
|
|
assert.deepEqual(requests.map((entry) => entry.channel), [
|
|
"netcatty:ai:jobStart",
|
|
"netcatty:ai:jobPoll",
|
|
]);
|
|
|
|
const stalePoll = await bridge.dispatchBuiltinRpc("netcatty/jobPoll", {
|
|
jobId: "worker-job-unpolled",
|
|
chatSessionId: "chat-unpolled",
|
|
});
|
|
assert.equal(stalePoll.ok, false);
|
|
assert.match(stalePoll.error, /not found/i);
|
|
});
|
|
|
|
test("MCP/Catty chat cancellation forwards to worker background jobs", async () => {
|
|
const requests = [];
|
|
const sends = [];
|
|
const bridge = loadFreshBridge();
|
|
bridge.init({
|
|
sessions: new Map(),
|
|
electronModule: null,
|
|
terminalWorkerManager: {
|
|
request(channel, payload, options) {
|
|
requests.push({ channel, payload, options });
|
|
if (channel === "netcatty:ai:jobStart") {
|
|
return Promise.resolve({
|
|
ok: true,
|
|
jobId: "worker-job-1",
|
|
sessionId: payload.sessionId,
|
|
command: payload.command,
|
|
status: "running",
|
|
});
|
|
}
|
|
return Promise.reject(new Error(`unexpected worker request: ${channel}`));
|
|
},
|
|
send(channel, payload, options) {
|
|
sends.push({ channel, payload, options });
|
|
},
|
|
},
|
|
});
|
|
bridge.setPermissionMode("auto");
|
|
bridge.setCommandBlocklist([]);
|
|
bridge.updateSessionMetadata([
|
|
{
|
|
sessionId: "ssh-1",
|
|
hostname: "host.example",
|
|
protocol: "ssh",
|
|
connected: true,
|
|
},
|
|
], "chat-1");
|
|
|
|
const started = await bridge.dispatchBuiltinRpc("netcatty/jobStart", {
|
|
sessionId: "ssh-1",
|
|
command: "sleep 30",
|
|
chatSessionId: "chat-1",
|
|
});
|
|
assert.equal(started.ok, true);
|
|
|
|
const cancelled = await bridge.applyChatSessionCancelled("chat-1", true);
|
|
assert.deepEqual(cancelled, {
|
|
ok: true,
|
|
chatSessionId: "chat-1",
|
|
cancelled: true,
|
|
});
|
|
assert.deepEqual(sends, [
|
|
{
|
|
channel: "netcatty:ai:catty:cancel",
|
|
payload: { chatSessionId: "chat-1" },
|
|
options: {},
|
|
},
|
|
]);
|
|
|
|
const pollAfterCancel = await bridge.dispatchBuiltinRpc("netcatty/jobPoll", {
|
|
jobId: "worker-job-1",
|
|
chatSessionId: "chat-1",
|
|
});
|
|
assert.deepEqual(pollAfterCancel, {
|
|
ok: false,
|
|
error: "Background job not found",
|
|
});
|
|
});
|
|
|
|
test("terminal close stops and forgets worker background jobs for that terminal", async () => {
|
|
const requests = [];
|
|
const bridge = loadFreshBridge();
|
|
bridge.init({
|
|
sessions: new Map(),
|
|
electronModule: null,
|
|
terminalWorkerManager: {
|
|
request(channel, payload, options) {
|
|
requests.push({ channel, payload, options });
|
|
if (channel === "netcatty:ai:jobStart") {
|
|
return Promise.resolve({ ok: true, jobId: "worker-job-close", status: "running" });
|
|
}
|
|
if (channel === "netcatty:ai:jobStop") {
|
|
return Promise.resolve({ ok: true, jobId: payload.jobId, completed: true });
|
|
}
|
|
return Promise.reject(new Error(`unexpected worker request: ${channel}`));
|
|
},
|
|
},
|
|
});
|
|
bridge.setPermissionMode("auto");
|
|
bridge.setCommandBlocklist([]);
|
|
bridge.updateSessionMetadata([{ sessionId: "ssh-close", protocol: "ssh", connected: true }], "chat-close");
|
|
|
|
const started = await bridge.dispatchBuiltinRpc("netcatty/jobStart", {
|
|
sessionId: "ssh-close",
|
|
command: "sleep 30",
|
|
chatSessionId: "chat-close",
|
|
});
|
|
assert.equal(started.ok, true);
|
|
|
|
await bridge.cancelWorkerBackgroundJobsForTerminalSession("ssh-close");
|
|
|
|
assert.equal(requests.at(-1).channel, "netcatty:ai:jobStop");
|
|
assert.equal(requests.at(-1).payload.jobId, "worker-job-close");
|
|
const polled = await bridge.dispatchBuiltinRpc("netcatty/jobPoll", {
|
|
jobId: "worker-job-close",
|
|
chatSessionId: "chat-close",
|
|
});
|
|
assert.deepEqual(polled, { ok: false, error: "Background job not found" });
|
|
});
|
|
|
|
test("terminal close stays bounded when worker job stop never responds", async () => {
|
|
const bridge = loadFreshBridge();
|
|
bridge.init({
|
|
sessions: new Map(),
|
|
electronModule: null,
|
|
terminalWorkerManager: {
|
|
request(channel) {
|
|
if (channel === "netcatty:ai:jobStart") {
|
|
return Promise.resolve({ ok: true, jobId: "worker-job-stalled", status: "running" });
|
|
}
|
|
if (channel === "netcatty:ai:jobStop") return new Promise(() => {});
|
|
return Promise.reject(new Error(`unexpected worker request: ${channel}`));
|
|
},
|
|
},
|
|
});
|
|
bridge.setPermissionMode("auto");
|
|
bridge.setCommandBlocklist([]);
|
|
bridge.setCommandTimeout(0.01);
|
|
bridge.updateSessionMetadata([
|
|
{ sessionId: "ssh-stalled-job", protocol: "ssh", connected: true },
|
|
], "chat-stalled-job");
|
|
|
|
const started = await bridge.dispatchBuiltinRpc("netcatty/jobStart", {
|
|
sessionId: "ssh-stalled-job",
|
|
command: "sleep 30",
|
|
chatSessionId: "chat-stalled-job",
|
|
});
|
|
assert.equal(started.ok, true);
|
|
|
|
const startedAt = Date.now();
|
|
await bridge.cancelWorkerBackgroundJobsForTerminalSession("ssh-stalled-job");
|
|
assert.ok(Date.now() - startedAt < 2000, "job cleanup should honor the bounded stop timeout");
|
|
|
|
const polled = await bridge.dispatchBuiltinRpc("netcatty/jobPoll", {
|
|
jobId: "worker-job-stalled",
|
|
chatSessionId: "chat-stalled-job",
|
|
});
|
|
assert.deepEqual(polled, { ok: false, error: "Background job not found" });
|
|
});
|
|
|
|
test("terminal close cancels a worker job start that finishes late", async () => {
|
|
let resolveStart;
|
|
const pendingStart = new Promise((resolve) => {
|
|
resolveStart = resolve;
|
|
});
|
|
const requests = [];
|
|
const bridge = loadFreshBridge();
|
|
bridge.init({
|
|
sessions: new Map(),
|
|
electronModule: null,
|
|
terminalWorkerManager: {
|
|
request(channel, payload) {
|
|
requests.push({ channel, payload });
|
|
if (channel === "netcatty:ai:jobStart") return pendingStart;
|
|
if (channel === "netcatty:ai:jobStop") {
|
|
return Promise.resolve({ ok: true, jobId: payload.jobId, completed: true });
|
|
}
|
|
return Promise.reject(new Error(`unexpected worker request: ${channel}`));
|
|
},
|
|
},
|
|
});
|
|
bridge.setPermissionMode("auto");
|
|
bridge.setCommandBlocklist([]);
|
|
bridge.updateSessionMetadata([
|
|
{ sessionId: "ssh-late-job", protocol: "ssh", connected: true },
|
|
], "chat-late-job");
|
|
|
|
const starting = bridge.dispatchBuiltinRpc("netcatty/jobStart", {
|
|
sessionId: "ssh-late-job",
|
|
command: "sleep 30",
|
|
chatSessionId: "chat-late-job",
|
|
});
|
|
await new Promise((resolve) => setImmediate(resolve));
|
|
await bridge.cancelWorkerBackgroundJobsForTerminalSession("ssh-late-job");
|
|
resolveStart({ ok: true, jobId: "worker-job-late", status: "running" });
|
|
|
|
const result = await starting;
|
|
assert.equal(result.ok, false);
|
|
assert.match(result.error, /closing/i);
|
|
assert.deepEqual(requests.map((entry) => entry.channel), [
|
|
"netcatty:ai:jobStart",
|
|
"netcatty:ai:jobStop",
|
|
]);
|
|
});
|