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
268 lines
8.3 KiB
JavaScript
268 lines
8.3 KiB
JavaScript
"use strict";
|
|
|
|
const assert = require("node:assert/strict");
|
|
const crypto = require("node:crypto");
|
|
const { EventEmitter } = require("node:events");
|
|
const test = require("node:test");
|
|
|
|
const { createBackgroundJobApi } = require("./mcpServerBridge/backgroundJobs.cjs");
|
|
const { createExecHandlerApi } = require("./mcpServerBridge/execHandlers.cjs");
|
|
const { PROBE_OUTPUT_MARKER } = require("./ai/sessionShellKind.cjs");
|
|
const {
|
|
checkBlocklistForShell,
|
|
resolveSessionBlocklistShellKind,
|
|
} = require("./ai/commandSafety.cjs");
|
|
|
|
class FakePty extends EventEmitter {
|
|
constructor() {
|
|
super();
|
|
this.writes = [];
|
|
}
|
|
|
|
write(data) {
|
|
this.writes.push(String(data));
|
|
}
|
|
}
|
|
|
|
function nextTick() {
|
|
return new Promise((resolve) => setImmediate(resolve));
|
|
}
|
|
|
|
function createDeferredShellProbeConn(stdout = `${PROBE_OUTPUT_MARKER}/usr/bin/fish\n`) {
|
|
let execCallback = null;
|
|
const conn = {
|
|
exec(_command, callback) {
|
|
execCallback = callback;
|
|
},
|
|
};
|
|
return {
|
|
conn,
|
|
release() {
|
|
assert.equal(typeof execCallback, "function", "probe should be waiting for ssh exec callback");
|
|
const stream = new EventEmitter();
|
|
stream.stderr = new EventEmitter();
|
|
stream.close = () => stream.emit("close");
|
|
execCallback(null, stream);
|
|
queueMicrotask(() => {
|
|
stream.emit("data", Buffer.from(stdout));
|
|
stream.emit("close");
|
|
});
|
|
},
|
|
};
|
|
}
|
|
|
|
function createExecHandlerTestContext({ sessions, backgroundJobs }) {
|
|
const ctx = {
|
|
sessions,
|
|
backgroundJobs,
|
|
activeSessionSftpOps: new Map(),
|
|
closingTerminalSessions: new Map(),
|
|
activeSessionExecutions: new Map(),
|
|
activePtyExecs: new Map(),
|
|
crypto,
|
|
sftpBridge: {},
|
|
BACKGROUND_JOB_RETENTION_MS: 10 * 60 * 1000,
|
|
DEFAULT_BACKGROUND_JOB_POLL_INTERVAL_MS: 30 * 1000,
|
|
MAX_BACKGROUND_JOB_OUTPUT_CHARS: 256 * 1024,
|
|
SESSION_CLOSE_CLEANUP_TIMEOUT_MS: 20,
|
|
DEFAULT_BACKGROUND_JOB_TIMEOUT_MS: 60 * 60 * 1000,
|
|
commandTimeoutMs: 5000,
|
|
activeSftpOpSeq: 0,
|
|
debugLog() {},
|
|
getSessionMeta() {
|
|
return {};
|
|
},
|
|
checkCommandSafetyForShell() {
|
|
return { blocked: false };
|
|
},
|
|
resolveSessionBlocklistShellKind() {
|
|
return "";
|
|
},
|
|
beginChatExecution() {
|
|
return { ok: true, release() {} };
|
|
},
|
|
getFreshIdlePrompt() {
|
|
return "";
|
|
},
|
|
echoCommandToSession() {},
|
|
validateSessionScope() {
|
|
return null;
|
|
},
|
|
};
|
|
Object.assign(ctx, createBackgroundJobApi(ctx));
|
|
return ctx;
|
|
}
|
|
|
|
test("SFTP cancellation targets one terminal session and waits for cleanup", async () => {
|
|
const ctx = createExecHandlerTestContext({ sessions: new Map(), backgroundJobs: new Map() });
|
|
const events = [];
|
|
ctx.registerSftpOp("chat-1", "session-1", async () => {
|
|
await nextTick();
|
|
events.push("session-1-clean");
|
|
});
|
|
ctx.registerSftpOp("chat-2", "session-1", () => {
|
|
events.push("session-1-other-scope-clean");
|
|
});
|
|
ctx.registerSftpOp("chat-1", "session-2", () => {
|
|
events.push("session-2-clean");
|
|
});
|
|
|
|
await ctx.cancelSftpOpsForTerminalSession("session-1");
|
|
|
|
assert.deepEqual(events, ["session-1-other-scope-clean", "session-1-clean"]);
|
|
assert.equal(ctx.activeSessionSftpOps.size, 1);
|
|
});
|
|
|
|
test("terminal close does not hang on stalled SFTP cleanup", async () => {
|
|
const ctx = createExecHandlerTestContext({ sessions: new Map(), backgroundJobs: new Map() });
|
|
ctx.activeSessionSftpOps.set("sftp-stalled", {
|
|
chatSessionId: "chat-1",
|
|
sessionId: "session-1",
|
|
cancel: () => new Promise(() => {}),
|
|
});
|
|
|
|
const startedAt = Date.now();
|
|
await ctx.cancelSftpOpsForTerminalSession("session-1");
|
|
|
|
assert.ok(Date.now() - startedAt < 200, "session close cleanup should be bounded");
|
|
assert.equal(ctx.activeSessionSftpOps.size, 0);
|
|
});
|
|
|
|
test("SFTP operations that start while a terminal is closing are cancelled immediately", () => {
|
|
const ctx = createExecHandlerTestContext({ sessions: new Map(), backgroundJobs: new Map() });
|
|
let cancelled = false;
|
|
ctx.beginTerminalSessionClose("session-1");
|
|
|
|
ctx.registerSftpOp("chat-1", "session-1", () => {
|
|
cancelled = true;
|
|
});
|
|
|
|
assert.equal(cancelled, true);
|
|
assert.equal(ctx.activeSessionSftpOps.size, 0);
|
|
ctx.endTerminalSessionClose("session-1");
|
|
});
|
|
|
|
test("overlapping closes keep SFTP blocked until the last close finishes", () => {
|
|
const ctx = createExecHandlerTestContext({ sessions: new Map(), backgroundJobs: new Map() });
|
|
ctx.beginTerminalSessionClose("session-1");
|
|
ctx.beginTerminalSessionClose("session-1");
|
|
ctx.endTerminalSessionClose("session-1");
|
|
|
|
let cancelled = false;
|
|
ctx.registerSftpOp("chat-1", "session-1", () => {
|
|
cancelled = true;
|
|
});
|
|
assert.equal(cancelled, true);
|
|
|
|
ctx.endTerminalSessionClose("session-1");
|
|
ctx.registerSftpOp("chat-1", "session-1", () => {});
|
|
assert.equal(ctx.activeSessionSftpOps.size, 1);
|
|
});
|
|
|
|
test("terminal close cancels and removes background jobs for that terminal", async () => {
|
|
let cancelCount = 0;
|
|
let settleJob;
|
|
const resultPromise = new Promise((resolve) => {
|
|
settleJob = resolve;
|
|
});
|
|
const backgroundJobs = new Map([
|
|
["job-1", {
|
|
sessionId: "session-1",
|
|
status: "running",
|
|
handle: {
|
|
cancel: () => { cancelCount += 1; },
|
|
resultPromise,
|
|
},
|
|
}],
|
|
["job-2", { sessionId: "session-2", status: "running", handle: {} }],
|
|
]);
|
|
const ctx = createExecHandlerTestContext({ sessions: new Map(), backgroundJobs });
|
|
ctx.activeSessionExecutions.set("session-1", { kind: "job", token: "token-1" });
|
|
|
|
ctx.cancelBackgroundJobsForTerminalSession("session-1");
|
|
assert.equal(cancelCount, 1);
|
|
assert.equal(backgroundJobs.get("job-1").status, "stopping");
|
|
|
|
const settling = ctx.settleBackgroundJobsForTerminalSession("session-1");
|
|
assert.equal(backgroundJobs.has("job-1"), true);
|
|
settleJob({ error: "Cancelled" });
|
|
await settling;
|
|
|
|
assert.equal(backgroundJobs.has("job-1"), false);
|
|
assert.equal(backgroundJobs.has("job-2"), true);
|
|
assert.equal(ctx.activeSessionExecutions.has("session-1"), false);
|
|
});
|
|
|
|
test("MCP terminal_start chat cancel during shellKind probe aborts before PTY write", async () => {
|
|
const pty = new FakePty();
|
|
const deferred = createDeferredShellProbeConn();
|
|
const sessions = new Map([
|
|
["ssh-fish", {
|
|
protocol: "ssh",
|
|
stream: pty,
|
|
conn: deferred.conn,
|
|
}],
|
|
]);
|
|
const backgroundJobs = new Map();
|
|
const ctx = createExecHandlerTestContext({ sessions, backgroundJobs });
|
|
const api = createExecHandlerApi(ctx);
|
|
|
|
const pendingStart = api.handleJobStart({
|
|
sessionId: "ssh-fish",
|
|
command: "sleep 999",
|
|
chatSessionId: "chat-cancel-probe",
|
|
});
|
|
await nextTick();
|
|
|
|
assert.equal(backgroundJobs.size, 1, "job should be registered while probe is pending");
|
|
ctx.cancelBackgroundJobsForSession("chat-cancel-probe");
|
|
|
|
deferred.release();
|
|
const started = await pendingStart;
|
|
|
|
assert.equal(started.ok, false);
|
|
assert.equal(started.error, "Cancelled");
|
|
assert.equal(started.status, "cancelled");
|
|
assert.equal(
|
|
pty.writes.filter((entry) => entry.includes("__NCMCP_")).length,
|
|
0,
|
|
"cancelled pending start must not type a wrapper into the PTY",
|
|
);
|
|
const [job] = backgroundJobs.values();
|
|
assert.equal(job.status, "cancelled");
|
|
assert.equal(job.pendingShellProbe, false);
|
|
assert.equal(ctx.activeSessionExecutions.size, 0);
|
|
});
|
|
|
|
test("MCP exec probes an unclassified PowerShell session before the authoritative check", async () => {
|
|
const pty = new FakePty();
|
|
const session = {
|
|
protocol: "ssh",
|
|
stream: pty,
|
|
shellKind: "",
|
|
remoteSshVersion: "OpenSSH_for_Windows_9.5",
|
|
lastIdlePrompt: "custom% ",
|
|
_promptTrackTail: "custom prompt\r\ncustom% ",
|
|
_shellKindExecProbe: async () => (
|
|
"DefaultShell REG_SZ C:\\Program Files\\PowerShell\\7\\pwsh.exe\r\n"
|
|
),
|
|
};
|
|
const ctx = createExecHandlerTestContext({
|
|
sessions: new Map([["ssh-powershell", session]]),
|
|
backgroundJobs: new Map(),
|
|
});
|
|
ctx.checkCommandSafetyForShell = checkBlocklistForShell;
|
|
ctx.resolveSessionBlocklistShellKind = resolveSessionBlocklistShellKind;
|
|
ctx.execViaPty = async () => ({ ok: true, stdout: "ok", stderr: "", exitCode: 0 });
|
|
const api = createExecHandlerApi(ctx);
|
|
|
|
const result = await api.handleExec({
|
|
sessionId: "ssh-powershell",
|
|
command: 'Write-Host "now: $(Get-Date)"',
|
|
chatSessionId: "chat-powershell",
|
|
});
|
|
|
|
assert.equal(result.ok, true);
|
|
assert.equal(session._loginShellKind, "powershell");
|
|
});
|