[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:
690
electron/bridges/mcpServerBridge.workerTerminal.test.cjs
Normal file
690
electron/bridges/mcpServerBridge.workerTerminal.test.cjs
Normal file
@@ -0,0 +1,690 @@
|
||||
"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",
|
||||
]);
|
||||
});
|
||||
Reference in New Issue
Block a user