[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

This commit is contained in:
2026-09-13 18:24:01 +08:00
commit 3c72efcb7f
3255 changed files with 907009 additions and 0 deletions

View File

@@ -0,0 +1,352 @@
/* eslint-disable no-undef */
function createBackgroundJobApi(ctx) {
with (ctx) {
async function waitForSessionCloseCleanup(pending) {
if (!pending.length) return;
const allSettled = Promise.allSettled(pending);
const timeoutMs = Number.isFinite(SESSION_CLOSE_CLEANUP_TIMEOUT_MS)
? Math.max(1, SESSION_CLOSE_CLEANUP_TIMEOUT_MS)
: 5000;
let timer = null;
const timeout = new Promise((resolve) => {
timer = setTimeout(resolve, timeoutMs);
});
await Promise.race([allSettled, timeout]);
if (timer) clearTimeout(timer);
}
function createBackgroundJobId() {
return `job_${Date.now().toString(36)}_${crypto.randomBytes(6).toString("hex")}`;
}
function cancelBackgroundJobsForSession(chatSessionId) {
if (!chatSessionId) return;
for (const [, job] of backgroundJobs) {
if (job.chatSessionId !== chatSessionId) continue;
if (job.status !== "running") continue;
try {
job.handle?.cancel?.();
job.status = "stopping";
job.error = "Cancellation requested";
job.updatedAt = Date.now();
} catch {
// Ignore cancellation failures
}
}
}
function cancelBackgroundJobsForTerminalSession(sessionId) {
if (!sessionId) return;
for (const [, job] of backgroundJobs) {
if (job.sessionId !== sessionId) continue;
if (job.status !== "running" && job.status !== "stopping") continue;
try {
job.handle?.cancel?.();
} catch {
// The terminal close below will still tear down the underlying stream.
}
job.status = "stopping";
job.error = "Cancellation requested";
job.updatedAt = Date.now();
}
}
async function settleBackgroundJobsForTerminalSession(sessionId) {
if (!sessionId) return;
const matchingJobs = [];
const pending = [];
for (const [jobId, job] of backgroundJobs) {
if (job.sessionId !== sessionId) continue;
matchingJobs.push([jobId, job]);
if (job.handle?.resultPromise) pending.push(job.handle.resultPromise);
}
await waitForSessionCloseCleanup(pending);
for (const [jobId] of matchingJobs) backgroundJobs.delete(jobId);
activeSessionExecutions.delete(sessionId);
}
function registerSftpOp(chatSessionId, sessionId, cancel) {
if (!chatSessionId || typeof cancel !== "function") {
return () => {};
}
if (closingTerminalSessions?.has(sessionId)) {
try {
void Promise.resolve(cancel()).catch(() => {});
} catch {
// The session is already closing; a failed redundant cancellation is harmless.
}
return () => {};
}
const opId = `sftp_${Date.now().toString(36)}_${(++activeSftpOpSeq).toString(36)}`;
activeSessionSftpOps.set(opId, { chatSessionId, sessionId, cancel });
return () => {
activeSessionSftpOps.delete(opId);
};
}
async function cancelSftpOpsForSession(chatSessionId) {
if (!chatSessionId) return;
const pending = [];
for (const [opId, entry] of activeSessionSftpOps) {
if (entry.chatSessionId !== chatSessionId) continue;
activeSessionSftpOps.delete(opId);
try {
pending.push(Promise.resolve(entry.cancel()));
} catch {
// Ignore cancellation failures for already-closed SFTP handles.
}
}
if (pending.length) {
await Promise.allSettled(pending);
}
}
async function cancelSftpOpsForTerminalSession(sessionId) {
if (!sessionId) return;
const pending = [];
for (const [opId, entry] of activeSessionSftpOps) {
if (entry.sessionId !== sessionId) continue;
activeSessionSftpOps.delete(opId);
try {
pending.push(Promise.resolve(entry.cancel()));
} catch {
// Ignore cancellation failures for already-closed SFTP handles.
}
}
await waitForSessionCloseCleanup(pending);
}
function beginTerminalSessionClose(sessionId) {
if (!sessionId) return;
const current = closingTerminalSessions?.get(sessionId) || 0;
closingTerminalSessions?.set(sessionId, current + 1);
}
function endTerminalSessionClose(sessionId) {
if (!sessionId) return;
const current = closingTerminalSessions?.get(sessionId) || 0;
if (current <= 1) closingTerminalSessions?.delete(sessionId);
else closingTerminalSessions?.set(sessionId, current - 1);
}
function cancelAllSftpOps() {
const pending = [];
for (const [opId, entry] of activeSessionSftpOps) {
activeSessionSftpOps.delete(opId);
try {
pending.push(Promise.resolve(entry.cancel()));
} catch {
// Ignore cancellation failures during global cleanup.
}
}
return pending.length ? Promise.allSettled(pending) : Promise.resolve([]);
}
function readBackgroundJobSnapshot(job) {
if (!job) {
return {
stdout: "",
outputBaseOffset: 0,
totalOutputChars: 0,
outputTruncated: false,
};
}
if (job.status === "running" || job.status === "stopping") {
const snapshot = job.handle?.getSnapshot?.();
if (snapshot) {
const stdout = String(snapshot.stdout || "");
const outputBaseOffset = Math.max(0, Number(snapshot.outputBaseOffset) || 0);
const totalOutputChars = Math.max(outputBaseOffset + stdout.length, Number(snapshot.totalOutputChars) || 0);
return {
stdout,
outputBaseOffset,
totalOutputChars,
outputTruncated: Boolean(snapshot.outputTruncated),
};
}
}
const stdout = String(job.stdout || "");
const outputBaseOffset = Math.max(0, Number(job.outputBaseOffset) || 0);
const totalOutputChars = Math.max(outputBaseOffset + stdout.length, Number(job.totalOutputChars) || 0);
return {
stdout,
outputBaseOffset,
totalOutputChars,
outputTruncated: Boolean(job.outputTruncated),
};
}
function createOutputWindow(stdout) {
const fullText = String(stdout || "");
const totalOutputChars = fullText.length;
const outputBaseOffset = Math.max(0, totalOutputChars - MAX_BACKGROUND_JOB_OUTPUT_CHARS);
return {
stdout: outputBaseOffset > 0 ? fullText.slice(outputBaseOffset) : fullText,
outputBaseOffset,
totalOutputChars,
outputTruncated: outputBaseOffset > 0,
};
}
function refreshRunningJobSnapshot(job) {
if (!job || (job.status !== "running" && job.status !== "stopping")) return;
const snapshot = readBackgroundJobSnapshot(job);
job.stdout = snapshot.stdout;
job.outputBaseOffset = snapshot.outputBaseOffset;
job.totalOutputChars = snapshot.totalOutputChars;
job.outputTruncated = snapshot.outputTruncated;
}
function storeCompletedJobOutput(job, stdout, metadata = null) {
if (metadata && typeof metadata === "object") {
const normalizedStdout = String(metadata.stdout ?? stdout ?? "");
const outputBaseOffset = Math.max(0, Number(metadata.outputBaseOffset) || 0);
const totalOutputChars = Math.max(outputBaseOffset + normalizedStdout.length, Number(metadata.totalOutputChars) || 0);
job.stdout = normalizedStdout;
job.outputBaseOffset = outputBaseOffset;
job.totalOutputChars = totalOutputChars;
job.outputTruncated = Boolean(metadata.outputTruncated);
job.handle = null;
return;
}
const window = createOutputWindow(stdout);
job.stdout = window.stdout;
job.outputBaseOffset = window.outputBaseOffset;
job.totalOutputChars = window.totalOutputChars;
job.outputTruncated = window.outputTruncated;
job.handle = null;
}
function pruneCompletedBackgroundJobs(now = Date.now()) {
for (const [jobId, job] of backgroundJobs) {
if (job.status === "running" || job.status === "stopping") continue;
const updatedAt = Number(job.updatedAt) || 0;
if (updatedAt > 0 && now - updatedAt > BACKGROUND_JOB_RETENTION_MS) {
backgroundJobs.delete(jobId);
}
}
}
// Collapse carriage-return progress redraws to the latest frame.
// Each \r resets the cursor to the start of the current line; the next
// non-\r character overwrites the existing line content. A trailing \r
// (with no following content) leaves the existing line intact, so a
// snapshot taken between redraws still shows the latest visible frame.
// Used at serialize time so the stored buffer can keep raw monotonic
// offsets while polled output shows the latest frame.
function collapseCarriageReturns(text) {
if (!text || text.indexOf("\r") === -1) return text;
let result = "";
let crPending = false;
for (let i = 0; i < text.length; i++) {
const ch = text[i];
if (ch === "\r") {
crPending = true;
continue;
}
if (ch === "\n") {
crPending = false;
result += ch;
continue;
}
if (crPending) {
const lastNl = result.lastIndexOf("\n");
result = lastNl >= 0 ? result.slice(0, lastNl + 1) : "";
crPending = false;
}
result += ch;
}
return result;
}
function serializeBackgroundJob(job, offset = 0) {
if (job.status === "running" || job.status === "stopping") {
refreshRunningJobSnapshot(job);
}
const stdout = job.stdout || "";
const outputBaseOffset = job.outputBaseOffset || 0;
const totalOutputChars = Math.max(outputBaseOffset + stdout.length, job.totalOutputChars || 0);
const numericOffset = Math.max(0, Number(offset) || 0);
const relativeOffset = numericOffset <= outputBaseOffset
? 0
: Math.min(numericOffset - outputBaseOffset, stdout.length);
return {
ok: true,
jobId: job.id,
sessionId: job.sessionId,
command: job.command,
status: job.status,
completed: job.status !== "running" && job.status !== "stopping",
exitCode: job.exitCode,
error: job.error,
startedAt: job.startedAt,
updatedAt: job.updatedAt,
output: collapseCarriageReturns(stdout.slice(relativeOffset)),
nextOffset: totalOutputChars,
totalOutputChars,
outputBaseOffset,
outputTruncated: Boolean(job.outputTruncated),
recommendedPollIntervalMs: DEFAULT_BACKGROUND_JOB_POLL_INTERVAL_MS,
};
}
function describeActiveSessionExecution(entry) {
if (!entry) return "another command";
return entry.kind === "job" ? "a long-running command" : "another command";
}
function getSessionBusyError(sessionId) {
const active = activeSessionExecutions.get(sessionId);
if (!active) return null;
return {
ok: false,
error: `Session already has ${describeActiveSessionExecution(active)} in progress. Wait for it to finish or stop it before starting another command.`,
};
}
function reserveSessionExecution(sessionId, kind) {
const existing = getSessionBusyError(sessionId);
if (existing) return existing;
const token = `${kind}_${Date.now().toString(36)}_${crypto.randomBytes(6).toString("hex")}`;
activeSessionExecutions.set(sessionId, {
kind,
startedAt: Date.now(),
token,
});
return { ok: true, token };
}
function releaseSessionExecution(sessionId, token) {
const active = activeSessionExecutions.get(sessionId);
if (!active) return;
if (token && active.token !== token) return;
activeSessionExecutions.delete(sessionId);
}
return {
createBackgroundJobId,
cancelBackgroundJobsForSession,
cancelBackgroundJobsForTerminalSession,
settleBackgroundJobsForTerminalSession,
registerSftpOp,
cancelSftpOpsForSession,
cancelSftpOpsForTerminalSession,
beginTerminalSessionClose,
endTerminalSessionClose,
cancelAllSftpOps,
readBackgroundJobSnapshot,
createOutputWindow,
refreshRunningJobSnapshot,
storeCompletedJobOutput,
pruneCompletedBackgroundJobs,
collapseCarriageReturns,
serializeBackgroundJob,
describeActiveSessionExecution,
getSessionBusyError,
reserveSessionExecution,
releaseSessionExecution,
};
}
}
module.exports = { createBackgroundJobApi };

View File

@@ -0,0 +1,35 @@
"use strict";
const { getCapabilityById } = require("../../capabilities/registry.cjs");
/**
* Build rpcMethod → handler map from capability-id-keyed handlers.
* Keeps dispatch aligned with catalog builtin RPC names.
*
* @param {Record<string, (params: object) => Promise<unknown> | unknown>} handlersByCapabilityId
* @returns {{ get: (rpcMethod: string) => ((params: object) => unknown) | null, has: (rpcMethod: string) => boolean }}
*/
function buildBuiltinRpcHandlerRegistry(handlersByCapabilityId) {
const byRpcMethod = new Map();
for (const [capabilityId, handler] of Object.entries(handlersByCapabilityId)) {
if (typeof handler !== "function") continue;
const capability = getCapabilityById(capabilityId);
const rpcMethod = capability?.surfaces?.builtin?.rpcMethod;
if (!rpcMethod) continue;
byRpcMethod.set(rpcMethod, handler);
}
return {
get(rpcMethod) {
return byRpcMethod.get(rpcMethod) || null;
},
has(rpcMethod) {
return byRpcMethod.has(rpcMethod);
},
};
}
module.exports = {
buildBuiltinRpcHandlerRegistry,
};

View File

@@ -0,0 +1,67 @@
"use strict";
const test = require("node:test");
const assert = require("node:assert/strict");
const { CAPABILITY_STATUS } = require("../../capabilities/constants.cjs");
const { ALL_CAPABILITIES } = require("../../capabilities/catalog/index.cjs");
const { buildBuiltinRpcHandlerRegistry } = require("./builtinRpcHandlers.cjs");
/** Capability ids wired in mcpServerBridge.getBuiltinRpcHandlerRegistry(). */
const MCP_BRIDGE_BUILTIN_CAPABILITY_IDS = [
"session.environment",
"meta.status",
"attachment.list",
"attachment.read",
"terminal.execute",
"sftp.list",
"sftp.read",
"sftp.write",
"sftp.download",
"sftp.upload",
"sftp.mkdir",
"sftp.delete",
"sftp.rename",
"sftp.stat",
"sftp.chmod",
"sftp.home",
"session.cancel",
"terminal.start",
"terminal.poll",
"terminal.stop",
];
test("buildBuiltinRpcHandlerRegistry maps catalog builtin rpcMethod to handlers", () => {
const handlersByCapabilityId = Object.fromEntries(
MCP_BRIDGE_BUILTIN_CAPABILITY_IDS.map((id) => [id, async () => ({ ok: true, id })]),
);
const registry = buildBuiltinRpcHandlerRegistry(handlersByCapabilityId);
for (const capabilityId of MCP_BRIDGE_BUILTIN_CAPABILITY_IDS) {
const capability = ALL_CAPABILITIES.find((entry) => entry.id === capabilityId);
assert.ok(capability, `missing catalog entry for ${capabilityId}`);
const rpcMethod = capability.surfaces?.builtin?.rpcMethod;
assert.ok(rpcMethod, `missing builtin rpcMethod for ${capabilityId}`);
assert.equal(typeof registry.get(rpcMethod), "function", rpcMethod);
}
});
test("every implemented netcatty/* builtin rpc has a bridge handler", () => {
const handlersByCapabilityId = Object.fromEntries(
MCP_BRIDGE_BUILTIN_CAPABILITY_IDS.map((id) => [id, async () => ({ ok: true })]),
);
const registry = buildBuiltinRpcHandlerRegistry(handlersByCapabilityId);
const implementedBuiltinRpcMethods = ALL_CAPABILITIES
.filter((capability) => capability.status === CAPABILITY_STATUS.IMPLEMENTED)
.map((capability) => capability.surfaces?.builtin?.rpcMethod)
.filter((rpcMethod) => typeof rpcMethod === "string" && rpcMethod.startsWith("netcatty/"));
const uniqueRpcMethods = [...new Set(implementedBuiltinRpcMethods)];
for (const rpcMethod of uniqueRpcMethods) {
assert.equal(
registry.has(rpcMethod),
true,
`no handler registered for ${rpcMethod}`,
);
}
});

View File

@@ -0,0 +1,179 @@
"use strict";
const { CAPABILITY_STATUS, CAPABILITY_SURFACES } = require("../../capabilities/constants.cjs");
const { getCapabilityByRpcMethod } = require("../../capabilities/registry.cjs");
const { getMcpToolNameForRpcMethod } = require("../../capabilities/adapters/mcpAdapter.cjs");
const { createVaultService } = require("../../capabilities/services/vaultService.cjs");
const { createPortForwardService } = require("../../capabilities/services/portforwardService.cjs");
const { createSessionService } = require("../../capabilities/services/sessionService.cjs");
const UNROUTED = Symbol("capability-rpc-unrouted");
const SERVICE_BINDINGS = Object.freeze({
"vault.host.get": { domain: "vault", method: "getHost" },
"vault.host.list": { domain: "vault", method: "listHosts" },
"vault.host.open": { domain: "vault", method: "openHost" },
"vault.hosts.create": { domain: "vault", method: "createHosts" },
"vault.host.update": { domain: "vault", method: "updateHost" },
"vault.host.delete": { domain: "vault", method: "deleteHost" },
"vault.host.import": { domain: "vault", method: "importHosts" },
"vault.host.notes.get": { domain: "vault", method: "getHostNotes" },
"vault.host.notes.set": { domain: "vault", method: "setHostNotes" },
"vault.note.list": { domain: "vault", method: "listNotes" },
"vault.note.get": { domain: "vault", method: "getNote" },
"vault.note.create": { domain: "vault", method: "createNote" },
"vault.note.update": { domain: "vault", method: "updateNote" },
"vault.note.delete": { domain: "vault", method: "deleteNote" },
"vault.identity.list": { domain: "vault", method: "listIdentities" },
"vault.proxyProfile.list": { domain: "vault", method: "listProxyProfiles" },
"vault.group.list": { domain: "vault", method: "listGroups" },
"vault.group.create": { domain: "vault", method: "createGroup" },
"vault.group.update": { domain: "vault", method: "updateGroup" },
"vault.group.delete": { domain: "vault", method: "deleteGroup" },
"vault.snippets.list": { domain: "vault", method: "listSnippets" },
"vault.snippets.get": { domain: "vault", method: "getSnippet" },
"vault.snippets.run": { domain: "vault", method: "runSnippet" },
"vault.snippets.create": { domain: "vault", method: "createSnippet" },
"vault.snippets.update": { domain: "vault", method: "updateSnippet" },
"vault.snippets.delete": { domain: "vault", method: "deleteSnippet" },
"vault.scripts.list": { domain: "vault", method: "listScripts" },
"vault.scripts.get": { domain: "vault", method: "getScript" },
"vault.scripts.create": { domain: "vault", method: "createScript" },
"vault.scripts.update": { domain: "vault", method: "updateScript" },
"vault.scripts.delete": { domain: "vault", method: "deleteScript" },
"vault.scripts.run": { domain: "vault", method: "runScript" },
"vault.scripts.reference": { domain: "vault", method: "getScriptReference" },
"vault.scripts.runs.list": { domain: "vault", method: "listScriptRuns" },
"vault.scripts.run.stop": { domain: "vault", method: "stopScriptRun" },
"vault.scripts.run.pause": { domain: "vault", method: "pauseScriptRun" },
"vault.scripts.run.resume": { domain: "vault", method: "resumeScriptRun" },
"vault.scripts.targets.set": { domain: "vault", method: "setScriptTargets" },
"vault.host.connectScripts.list": { domain: "vault", method: "listHostConnectScripts" },
"vault.host.connectScripts.set": { domain: "vault", method: "setHostConnectScripts" },
"portforward.rules.list": { domain: "portforward", method: "listRules" },
"portforward.rules.create": { domain: "portforward", method: "createRule" },
"portforward.rules.update": { domain: "portforward", method: "updateRule" },
"portforward.rules.duplicate": { domain: "portforward", method: "duplicateRule" },
"portforward.rules.delete": { domain: "portforward", method: "deleteRule" },
"portforward.tunnels.list": { domain: "portforward", method: "listTunnels" },
"portforward.start": { domain: "portforward", method: "start" },
"portforward.stop": { domain: "portforward", method: "stop" },
"session.close": { domain: "session", method: "close" },
});
function resolveCapabilitySurface(rpcMethod) {
for (const surface of [
CAPABILITY_SURFACES.PUBLIC,
CAPABILITY_SURFACES.GLOBAL,
CAPABILITY_SURFACES.BUILTIN,
]) {
if (rpcMethod.startsWith("netcatty/") && surface !== CAPABILITY_SURFACES.BUILTIN) {
continue;
}
const capability = getCapabilityByRpcMethod(rpcMethod, surface);
if (capability) {
return { capability, surface };
}
}
return null;
}
function createCapabilityRpcDispatcher(deps) {
const {
invokeVaultAgent,
evaluatePermissionWithGrants,
isChatSessionCancelled,
requestApprovalFromRenderer,
USER_DENIED_MESSAGE,
} = deps;
const vaultService = createVaultService({ invokeVaultAgent });
const portforwardService = createPortForwardService({
invokeVaultAgent,
listPortForwards: deps.listPortForwards,
});
const sessionService = deps.sessionService || createSessionService({
invokeSessionAgent: invokeVaultAgent,
validateClose: deps.validateSessionClose,
beforeClose: deps.beforeSessionClose,
afterClose: deps.afterSessionClose,
onClosed: deps.onSessionClosed,
});
const services = {
vault: vaultService,
portforward: portforwardService,
session: sessionService,
};
return async function dispatchCapabilityRpc(rpcMethod, params = {}) {
if (typeof rpcMethod !== "string" || rpcMethod.startsWith("netcatty/")) {
return UNROUTED;
}
const resolved = resolveCapabilitySurface(rpcMethod);
if (!resolved) {
return UNROUTED;
}
const { capability, surface } = resolved;
if (capability.status !== CAPABILITY_STATUS.IMPLEMENTED) {
return {
ok: false,
code: "CAPABILITY_NOT_IMPLEMENTED",
error: `Capability "${capability.id}" is not implemented yet.`,
};
}
const binding = SERVICE_BINDINGS[capability.id];
if (!binding) {
return UNROUTED;
}
const permission = evaluatePermissionWithGrants({
rpcMethod,
surface,
permissionMode: deps.permissionMode,
params,
context: {
chatSessionCancelled: isChatSessionCancelled(params?.chatSessionId),
},
}, deps.permissionGrantsSnapshot);
if (!permission.allowed) {
return { ok: false, error: permission.error };
}
if (permission.requiresApproval) {
const { chatSessionId, ...toolArgs } = params || {};
const toolName = getMcpToolNameForRpcMethod(rpcMethod, surface) || capability.id;
const approved = await requestApprovalFromRenderer(toolName, toolArgs, chatSessionId);
if (!approved) {
return { ok: false, error: USER_DENIED_MESSAGE };
}
}
const service = services[binding.domain];
const handler = service?.[binding.method];
if (typeof handler !== "function") {
return {
ok: false,
error: `Capability handler "${capability.id}" is unavailable.`,
};
}
const hostOpenGeneration = capability.id === "vault.host.open"
? deps.captureHostOpenScope?.(params?.chatSessionId)
: null;
const result = await handler(params);
if (capability.id === "vault.host.open" && result?.ok !== false && result?.sessionId) {
await deps.onHostOpened?.(params?.chatSessionId, result.sessionId, hostOpenGeneration, result);
}
return result;
};
}
module.exports = {
UNROUTED,
createCapabilityRpcDispatcher,
SERVICE_BINDINGS,
};

View File

@@ -0,0 +1,321 @@
"use strict";
const test = require("node:test");
const assert = require("node:assert/strict");
const { createCapabilityRpcDispatcher, UNROUTED } = require("./capabilityRpcDispatch.cjs");
const { CAPABILITY_SURFACES, PERMISSION_MODES } = require("../../capabilities/constants.cjs");
function createTestDispatcher(overrides = {}) {
const invokeVaultAgent = overrides.invokeVaultAgent || (async (op, params) => ({
ok: true,
op,
params,
}));
const requestApprovalFromRenderer = overrides.requestApprovalFromRenderer
|| (async () => true);
return createCapabilityRpcDispatcher({
invokeVaultAgent,
evaluatePermissionWithGrants: overrides.evaluatePermissionWithGrants || ((input, grants) => ({
allowed: true,
requiresApproval: false,
grants,
...input,
})),
permissionMode: overrides.permissionMode || PERMISSION_MODES.CONFIRM,
permissionGrantsSnapshot: [],
isChatSessionCancelled: () => false,
requestApprovalFromRenderer,
USER_DENIED_MESSAGE: "User denied the operation.",
...overrides,
});
}
test("dispatchCapabilityRpc returns UNROUTED for netcatty builtin methods", async () => {
const dispatch = createTestDispatcher();
const result = await dispatch("netcatty/exec", { chatSessionId: "chat-1" });
assert.equal(result, UNROUTED);
});
test("dispatchCapabilityRpc routes vault host notes get to vault service", async () => {
let invokedOp = null;
const dispatch = createTestDispatcher({
invokeVaultAgent: async (op, params) => {
invokedOp = op;
return { ok: true, hostId: params.hostId, notes: "notes" };
},
});
const result = await dispatch("vault/host/notes/get", { hostId: "host-1" });
assert.equal(invokedOp, "host.notes.get");
assert.equal(result.ok, true);
assert.equal(result.notes, "notes");
});
test("dispatchCapabilityRpc routes public vault host notes set through approval", async () => {
const approvalCalls = [];
const dispatch = createTestDispatcher({
evaluatePermissionWithGrants: () => ({
allowed: true,
requiresApproval: true,
}),
requestApprovalFromRenderer: async (toolName, args, chatSessionId) => {
approvalCalls.push({ toolName, args, chatSessionId });
return true;
},
invokeVaultAgent: async (op, params) => ({
ok: true,
op,
hostId: params.hostId,
notes: params.notes,
}),
});
const result = await dispatch("public/vault/hostNotes/set", {
chatSessionId: "chat-1",
hostId: "host-1",
notes: "updated",
});
assert.equal(approvalCalls.length, 1);
assert.equal(approvalCalls[0].toolName, "host_notes_set");
assert.equal(result.ok, true);
assert.equal(result.notes, "updated");
});
test("dispatchCapabilityRpc denies public vault host notes set when approval rejected", async () => {
const dispatch = createTestDispatcher({
evaluatePermissionWithGrants: () => ({
allowed: true,
requiresApproval: true,
}),
requestApprovalFromRenderer: async () => false,
});
const result = await dispatch("public/vault/hostNotes/set", {
chatSessionId: "chat-1",
hostId: "host-1",
notes: "updated",
});
assert.equal(result.ok, false);
assert.match(result.error, /denied/i);
});
test("dispatchCapabilityRpc closes an owned session through the renderer bridge", async () => {
const calls = [];
const closed = [];
const lifecycle = [];
const dispatch = createTestDispatcher({
validateSessionClose: (params) => {
assert.equal(params.chatSessionId, "chat-1");
assert.equal(params.sessionId, "session-1");
return { ok: true };
},
beforeSessionClose: async () => {
await Promise.resolve();
lifecycle.push("sftp-clean");
},
afterSessionClose: async () => {
lifecycle.push("close-finished");
},
onSessionClosed: async (sessionId) => {
await Promise.resolve();
lifecycle.push("session-jobs-settled");
closed.push(sessionId);
},
invokeVaultAgent: async (op, params) => {
lifecycle.push("session-close");
calls.push({ op, params });
return { ok: true, sessionId: params.sessionId, status: "closed" };
},
});
const result = await dispatch("public/session/close", {
chatSessionId: "chat-1",
sessionId: "session-1",
});
assert.equal(result.ok, true);
assert.deepEqual(calls, [{ op: "session.close", params: { sessionId: "session-1" } }]);
assert.deepEqual(closed, ["session-1"]);
assert.deepEqual(lifecycle, ["sftp-clean", "session-close", "session-jobs-settled", "close-finished"]);
});
test("dispatchCapabilityRpc refuses to close a session outside ownership", async () => {
let invoked = false;
const dispatch = createTestDispatcher({
validateSessionClose: () => ({ ok: false, error: "not owned" }),
invokeVaultAgent: async () => {
invoked = true;
return { ok: true };
},
});
const result = await dispatch("public/session/close", {
chatSessionId: "chat-1",
sessionId: "session-2",
});
assert.equal(result.ok, false);
assert.equal(invoked, false);
});
test("dispatchCapabilityRpc preserves the host-open scope generation across the async bridge", async () => {
const registrations = [];
const dispatch = createTestDispatcher({
captureHostOpenScope: (chatSessionId) => {
assert.equal(chatSessionId, "chat-1");
return 7;
},
onHostOpened: (chatSessionId, sessionId, generation, result) => {
registrations.push({ chatSessionId, sessionId, generation, result });
},
invokeVaultAgent: async () => ({ ok: true, sessionId: "session-1" }),
});
await dispatch("public/vault/hosts/open", { chatSessionId: "chat-1", hostId: "host-1" });
assert.deepEqual(registrations, [{
chatSessionId: "chat-1",
sessionId: "session-1",
generation: 7,
result: { ok: true, sessionId: "session-1" },
}]);
});
test("dispatchCapabilityRpc routes vault hosts create to vault service", async () => {
let invokedOp = null;
const dispatch = createTestDispatcher({
invokeVaultAgent: async (op, params) => {
invokedOp = op;
return { ok: true, addedCount: 1, previewHosts: [] , params };
},
});
const result = await dispatch("vault/hosts/create", {
hosts: JSON.stringify([{ hostname: "10.2.0.209", username: "root" }]),
dryRun: "true",
});
assert.equal(invokedOp, "hosts.create");
assert.equal(result.ok, true);
});
test("dispatchCapabilityRpc routes vault host update to vault service", async () => {
let invokedOp = null;
let invokedParams = null;
const dispatch = createTestDispatcher({
invokeVaultAgent: async (op, params) => {
invokedOp = op;
invokedParams = params;
return { ok: true, hostId: params.hostId };
},
});
const result = await dispatch("vault/hosts/update", {
hostId: "host-1",
label: "updated",
});
assert.equal(invokedOp, "host.update");
assert.equal(invokedParams.label, "updated");
assert.equal(result.ok, true);
});
test("dispatchCapabilityRpc routes vault host delete to vault service", async () => {
let invokedOp = null;
const dispatch = createTestDispatcher({
invokeVaultAgent: async (op, params) => {
invokedOp = op;
return { ok: true, hostId: params.hostId };
},
});
const result = await dispatch("vault/hosts/delete", { hostId: "host-1" });
assert.equal(invokedOp, "host.delete");
assert.equal(result.ok, true);
});
test("dispatchCapabilityRpc routes vault hosts import to vault service", async () => {
let invokedOp = null;
const dispatch = createTestDispatcher({
invokeVaultAgent: async (op) => {
invokedOp = op;
return { ok: true, addedCount: 0 };
},
});
const result = await dispatch("vault/hosts/import", {
format: "csv",
text: "hostname,username\n10.0.0.1,root\n",
dryRun: "true",
});
assert.equal(invokedOp, "host.import");
assert.equal(result.ok, true);
});
test("dispatchCapabilityRpc routes portforward start to portforward service", async () => {
let invokedOp = null;
const dispatch = createTestDispatcher({
invokeVaultAgent: async (op, params) => {
invokedOp = op;
return { ok: true, ruleId: params.ruleId, status: "active" };
},
});
const result = await dispatch("portforward/start", {
chatSessionId: "chat-1",
ruleId: "rule-1",
});
assert.equal(invokedOp, "portforward.start");
assert.equal(result.ok, true);
assert.equal(result.ruleId, "rule-1");
});
test("dispatchCapabilityRpc lists port forwards from the configured runtime", async () => {
const calls = [];
const dispatch = createTestDispatcher({
listPortForwards: async () => {
calls.push("list");
return [{ tunnelId: "worker-pf", status: "active" }];
},
});
const result = await dispatch("portforward/tunnels/list", {});
assert.deepEqual(result, {
ok: true,
tunnels: [{ tunnelId: "worker-pf", status: "active" }],
});
assert.deepEqual(calls, ["list"]);
});
test("dispatchCapabilityRpc reads permissionMode from deps on each call", async () => {
const seenModes = [];
const mutableDeps = { permissionMode: PERMISSION_MODES.CONFIRM };
const liveDispatch = createCapabilityRpcDispatcher({
invokeVaultAgent: async () => ({ ok: true }),
evaluatePermissionWithGrants: (input) => {
seenModes.push(input.permissionMode);
return { allowed: true, requiresApproval: false };
},
get permissionMode() {
return mutableDeps.permissionMode;
},
permissionGrantsSnapshot: [],
isChatSessionCancelled: () => false,
requestApprovalFromRenderer: async () => true,
USER_DENIED_MESSAGE: "User denied the operation.",
});
await liveDispatch("vault/host/get", { hostId: "host-1" });
mutableDeps.permissionMode = PERMISSION_MODES.AUTO;
await liveDispatch("vault/host/get", { hostId: "host-2" });
assert.deepEqual(seenModes, [PERMISSION_MODES.CONFIRM, PERMISSION_MODES.AUTO]);
});
test("implemented vault capabilities do not return CAPABILITY_NOT_IMPLEMENTED", async () => {
const dispatch = createTestDispatcher();
const result = await dispatch("vault/host/get", { hostId: "host-1" });
assert.notEqual(result.code, "CAPABILITY_NOT_IMPLEMENTED");
});

View File

@@ -0,0 +1,91 @@
/* eslint-disable no-undef */
function createConfigAndCleanupApi(ctx) {
with (ctx) {
function resolveMcpServerRuntimeCommand() {
const runtimeCommand = process.execPath;
const runtimeEnv = [];
if (runtimeCommand && existsSync(runtimeCommand)) {
const basename = path.basename(runtimeCommand).toLowerCase();
const isNodeBinary = basename === "node" || basename.startsWith("node.");
if (!isNodeBinary) {
runtimeEnv.push({ name: "ELECTRON_RUN_AS_NODE", value: "1" });
}
return { command: runtimeCommand, env: runtimeEnv };
}
return { command: "node", env: runtimeEnv };
}
function buildMcpServerConfig(port, scopedSessionIds, chatSessionId) {
// Use provided scoped IDs, or resolve them from chatSessionId.
const effectiveIds = (scopedSessionIds && scopedSessionIds.length > 0)
? scopedSessionIds
: getScopedSessionIds(chatSessionId);
const runtimePath = toUnpackedAsarPath(
path.join(__dirname, "..", "mcp", "netcatty-mcp-server.cjs"),
);
const runtime = resolveMcpServerRuntimeCommand();
const env = [
...runtime.env,
{ name: "NETCATTY_MCP_PORT", value: String(port) },
];
if (authToken) {
env.push({ name: "NETCATTY_MCP_TOKEN", value: authToken });
}
if (DEBUG_MCP) {
env.push({ name: "NETCATTY_MCP_DEBUG", value: "1" });
}
// When chatSessionId is present, the MCP subprocess resolves scope dynamically
// through main-process metadata, so avoid freezing session IDs at spawn time.
if (!chatSessionId && effectiveIds && effectiveIds.length > 0) {
env.push({ name: "NETCATTY_MCP_SESSION_IDS", value: effectiveIds.join(",") });
}
// Pass chatSessionId so MCP server can scope getContext responses
if (chatSessionId) {
env.push({ name: "NETCATTY_MCP_CHAT_SESSION_ID", value: chatSessionId });
}
// Pass permission mode so MCP server can enforce it locally (defense-in-depth)
env.push({ name: "NETCATTY_MCP_PERMISSION_MODE", value: permissionMode });
return {
name: "netcatty-remote-hosts",
type: "stdio",
command: runtime.command,
args: [runtimePath],
env,
};
}
// ── Cleanup ──
async function cleanupScopedMetadata(chatSessionId) {
if (chatSessionId) {
scopedMetadata.delete(chatSessionId);
scopedAttachments.delete(chatSessionId);
preserveIdleSessionCleanup?.(chatSessionId);
clearOpenedSessionScope?.(chatSessionId);
cancelledChatSessions.delete(chatSessionId);
cancelBackgroundJobsForSession(chatSessionId);
cancelWorkerBackgroundJobsForSession(chatSessionId);
// Resolve any in-flight approval requests so dispatch()'s finally block
// releases its pendingSessionWriteApprovals entry. Without this, a chat
// deleted while an approval was pending would leave the per-session
// write lock held until the approval timeout expires.
clearPendingApprovals(chatSessionId);
await cancelSftpOpsForSession(chatSessionId);
sftpBridge.clearSftpEncodingStateByPrefix?.(`chat:${chatSessionId}:session:`);
}
}
return { resolveMcpServerRuntimeCommand, buildMcpServerConfig, cleanupScopedMetadata };
}
}
module.exports = { createConfigAndCleanupApi };

View File

@@ -0,0 +1,472 @@
/* eslint-disable no-undef */
// Module-level require: code inside createExecHandlerApi runs under `with (ctx)`
// where bare `require` resolves to ctx.require (based in electron/bridges/).
const {
ensureSessionShellKind,
ensureSessionShellKindForExec,
} = require("../ai/sessionShellKind.cjs");
function createExecHandlerApi(ctx) {
with (ctx) {
function resolveExecContext(params) {
const { sessionId, command } = params;
debugLog("handleExec:start", { sessionId, command, chatSessionId: params?.chatSessionId });
if (!sessionId || !command) throw new Error("sessionId and command are required");
if (typeof command !== 'string' || !command.trim()) {
return { ok: false, error: 'Invalid command', exitCode: 1 };
}
const session = sessions?.get(sessionId);
debugLog("handleExec:sessionLookup", {
sessionId,
found: Boolean(session),
protocol: session?.protocol || session?.type || null,
shellKind: session?.shellKind || null,
});
if (!session) return { ok: false, error: "Session not found" };
// Look up device type from metadata (set by renderer from Host.deviceType).
const chatSessionId = params?.chatSessionId || null;
const meta = getSessionMeta(sessionId, chatSessionId) || {};
// Mosh sessions use a shell-backed PTY and cannot connect to vendor CLIs,
// so network device mode only applies to SSH and serial sessions.
// Prefer session.protocol (runtime truth) over meta.protocol (renderer hint)
// because Mosh tabs report as protocol:"ssh" in metadata but "mosh" in session.
const sessionProtocol = session.protocol || session.type || meta.protocol || "";
const isSshOrSerial = sessionProtocol === "ssh" || sessionProtocol === "serial";
const isNetworkDevice = (meta.deviceType === "network" && isSshOrSerial) || sessionProtocol === "serial";
// The blocklist targets shell-specific patterns (rm -rf, eval, $(), etc.) that
// are meaningless on network device CLIs. Serial sessions skip the check because
// commands like "shutdown" (disable an interface) are routine on Cisco/Huawei.
//
// Design note: the serial protocol is explicitly chosen by the user in the UI
// for network devices / embedded systems. While startSerialSession technically
// supports PTY devices, users connecting to a Linux/BusyBox shell should use
// the "local" protocol (which goes through the normal shell path with blocklist).
// Additionally, execViaRawPty sends commands without shell wrapping, so shell
// metacharacters in blocklist patterns (eval, $(), backticks, pipes) cannot
// actually be interpreted even if sent to a serial-connected shell.
if ((session.protocol === "local" || session.type === "local") && session.shellKind === "unknown") {
return {
ok: false,
error: "AI execution is not supported for this local shell executable. Configure the local terminal to use bash/zsh/sh, fish, PowerShell/pwsh, or cmd.exe.",
};
}
const sshClient = session.conn || session.sshClient;
const ptyStream = session.stream || session.pty || session.proc;
return {
ok: true,
context: {
sessionId,
command,
session,
chatSessionId,
sessionProtocol,
isNetworkDevice,
sshClient,
ptyStream,
},
};
}
function handleExec(params) {
const resolved = resolveExecContext(params);
if (!resolved.ok) return resolved;
const {
sessionId,
command,
session,
chatSessionId,
sessionProtocol,
isNetworkDevice,
sshClient,
ptyStream,
} = resolved.context;
const reservation = reserveSessionExecution(sessionId, "exec");
if (!reservation.ok) return reservation;
const sessionToken = reservation.token;
const executionLock = beginChatExecution(chatSessionId, sessionId, command);
if (!executionLock.ok) {
releaseSessionExecution(sessionId, sessionToken);
return {
ok: false,
code: "COMMAND_ALREADY_RUNNING",
error: `Another Netcatty command is already running for chat session "${chatSessionId}". Wait for it to finish before starting a new exec.`,
activeCommand: executionLock.active.command,
activeSessionId: executionLock.active.sessionId,
};
}
const runExecution = (factory) => {
try {
return Promise.resolve(factory()).finally(() => {
releaseSessionExecution(sessionId, sessionToken);
executionLock.release();
});
} catch (err) {
releaseSessionExecution(sessionId, sessionToken);
executionLock.release();
return { ok: false, error: err?.message || String(err) };
}
};
// Network devices (switches/routers) connected via SSH: use raw execution.
// Their vendor CLIs (Huawei VRP, Cisco IOS, etc.) don't run a POSIX shell,
// so shell-wrapped commands with markers would fail. Raw mode sends commands
// as-is with idle-timeout completion detection — same as serial sessions.
if (isNetworkDevice && ptyStream && typeof ptyStream.write === "function") {
return runExecution(() => execViaRawPty(ptyStream, command, {
timeoutMs: commandTimeoutMs,
trackForCancellation: activePtyExecs,
chatSessionId: params?.chatSessionId,
encoding: "utf8", // SSH PTY streams use UTF-8, not latin1
}));
}
// Prefer the interactive PTY so the user sees command/output in-session.
if (ptyStream && typeof ptyStream.write === "function") {
// Probe remote login shell once when shellKind is unset so fish
// sessions get the fish wrapper instead of the posix default (#1854).
// Cancellable: Stop during the probe must not still type the command.
return runExecution(async () => {
const probed = await ensureSessionShellKindForExec(session, {
trackForCancellation: activePtyExecs,
chatSessionId,
});
if (!probed.ok) return probed;
const safety = checkCommandSafetyForShell(command, resolveSessionBlocklistShellKind(session));
if (safety.blocked) {
debugLog("handleExec:blocklisted", { sessionId, matchedPattern: safety.matchedPattern });
return { ok: false, error: `Command blocked by safety policy. Pattern: ${safety.matchedPattern}` };
}
return execViaPty(ptyStream, command, {
trackForCancellation: activePtyExecs,
timeoutMs: commandTimeoutMs,
shellKind: session.shellKind,
loginShellHint: session._loginShellKind,
probeLiveShell: true,
onProbeAborted: (marker) => echoCommandToSession(session, sessionId, `${marker}_R`, { syntheticEcho: false }),
expectedPrompt: getFreshIdlePrompt(session),
typedInput: true,
echoCommand: (rawCommand) => echoCommandToSession(session, sessionId, rawCommand),
chatSessionId,
// MCP callers have terminal_start as a fallback for long commands,
// so enforce a hard wall-clock timeout here to match the MCP budget.
enforceWallTimeout: true,
});
});
}
// Network devices require an interactive PTY for raw command execution.
// If we got here, ptyStream wasn't writable — there's no usable channel.
if (isNetworkDevice) {
releaseSessionExecution(sessionId, sessionToken);
executionLock.release();
return { ok: false, error: "Network device session has no writable PTY stream for command execution" };
}
// Fallback: SSH exec channel (invisible to terminal).
// At this point ptyStream is not writable (already returned above if it was).
if (sshClient && typeof sshClient.exec === "function") {
return runExecution(async () => {
const probed = await ensureSessionShellKindForExec(session, {
trackForCancellation: activePtyExecs,
chatSessionId,
});
if (!probed.ok) return probed;
const safety = checkCommandSafetyForShell(command, resolveSessionBlocklistShellKind(session));
if (safety.blocked) {
debugLog("handleExec:blocklisted", { sessionId, matchedPattern: safety.matchedPattern });
return { ok: false, error: `Command blocked by safety policy. Pattern: ${safety.matchedPattern}` };
}
return execViaChannel(sshClient, command, {
timeoutMs: commandTimeoutMs,
trackForCancellation: activePtyExecs,
// Pass chatSessionId so cancelPtyExecsForSession can interrupt this
// exec channel when the originating SDK agent run is stopped.
chatSessionId: params?.chatSessionId,
});
});
}
// Serial port: raw command execution (no shell wrapping)
if (session.protocol === "serial" && session.serialPort && typeof session.serialPort.write === "function") {
if (session.ymodemActive || session.zmodemSentry?.isActive?.()) {
releaseSessionExecution(sessionId, sessionToken);
executionLock.release();
return { ok: false, error: "Serial file transfer is already in progress" };
}
return runExecution(() => execViaRawPty(session.serialPort, command, {
timeoutMs: commandTimeoutMs,
trackForCancellation: activePtyExecs,
chatSessionId: params?.chatSessionId,
encoding: session.serialEncoding || "utf8",
}));
}
releaseSessionExecution(sessionId, sessionToken);
executionLock.release();
return { ok: false, error: "Session does not support command execution" };
}
function handleJobStart(params) {
const resolved = resolveExecContext(params);
if (!resolved.ok) return resolved;
const {
sessionId,
command,
session,
chatSessionId,
isNetworkDevice,
sessionProtocol,
ptyStream,
} = resolved.context;
if (isNetworkDevice || sessionProtocol === "serial") {
return {
ok: false,
error: "Background execution currently supports shell-backed PTY sessions only.",
};
}
if (!ptyStream || typeof ptyStream.write !== "function") {
return {
ok: false,
error: "Background execution requires a writable PTY-backed terminal session.",
};
}
const reservation = reserveSessionExecution(sessionId, "job");
if (!reservation.ok) return reservation;
const sessionToken = reservation.token;
const jobId = createBackgroundJobId();
const timeoutMs = Math.max(commandTimeoutMs, DEFAULT_BACKGROUND_JOB_TIMEOUT_MS);
const startedAt = Date.now();
// Register the job *before* the shell-kind probe so chat-delete /
// cancelBackgroundJobsForSession can see it while we await. Without
// this, the first terminal_start on an unprobed remote session has no
// backgroundJobs entry during the probe and still starts the PTY job
// after cancel (Codex P2 on #2061). Not registered in activePtyExecs —
// terminal_start is designed to survive SDK Stop; only chat cancel
// (which walks backgroundJobs) should abort a pending start.
let probeCancelRequested = false;
const job = {
id: jobId,
sessionId,
chatSessionId: chatSessionId || null,
command,
status: "running",
startedAt,
updatedAt: startedAt,
exitCode: null,
error: null,
stdout: "",
outputBaseOffset: 0,
totalOutputChars: 0,
outputTruncated: false,
pendingShellProbe: true,
handle: {
cancel: () => {
probeCancelRequested = true;
},
},
};
backgroundJobs.set(jobId, job);
// Probe so fish login shells are not mis-wrapped as posix (#1854).
return Promise.resolve(ensureSessionShellKind(session)).then(() => {
if (probeCancelRequested || job.status === "stopping") {
job.status = "cancelled";
job.error = "Cancelled";
job.updatedAt = Date.now();
job.pendingShellProbe = false;
releaseSessionExecution(sessionId, sessionToken);
return {
ok: false,
error: "Cancelled",
jobId,
sessionId,
status: "cancelled",
};
}
const safety = checkCommandSafetyForShell(command, resolveSessionBlocklistShellKind(session));
if (safety.blocked) {
job.status = "failed";
job.error = `Command blocked by safety policy. Pattern: ${safety.matchedPattern}`;
job.updatedAt = Date.now();
job.pendingShellProbe = false;
backgroundJobs.delete(jobId);
releaseSessionExecution(sessionId, sessionToken);
return { ok: false, error: job.error };
}
let handle;
try {
handle = startPtyJob(ptyStream, command, {
// Intentionally do NOT register in activePtyExecs: terminal_start jobs
// are designed to survive SDK agent "Stop" so the model can stop polling
// without aborting a long-running build/scan/log stream. The job is
// managed via terminal_stop and the per-session execution lock.
timeoutMs,
shellKind: session.shellKind,
loginShellHint: session._loginShellKind,
probeLiveShell: true,
onProbeAborted: (marker) => echoCommandToSession(session, sessionId, `${marker}_R`, { syntheticEcho: false }),
chatSessionId,
expectedPrompt: getFreshIdlePrompt(session),
typedInput: true,
echoCommand: (rawCommand) => echoCommandToSession(session, sessionId, rawCommand),
maxBufferedChars: MAX_BACKGROUND_JOB_OUTPUT_CHARS,
normalizeFinalOutput: false,
});
} catch (err) {
job.status = "failed";
job.error = err?.message || String(err);
job.updatedAt = Date.now();
job.pendingShellProbe = false;
releaseSessionExecution(sessionId, sessionToken);
return { ok: false, error: err?.message || String(err) };
}
job.handle = handle;
job.pendingShellProbe = false;
handle.resultPromise.then((result) => {
job.updatedAt = Date.now();
job.exitCode = result.exitCode ?? null;
storeCompletedJobOutput(job, result.stdout || "", result);
const isForcedCancel = typeof result.error === "string" && result.error.includes("forced");
if (result.error === "Cancelled" || isForcedCancel) {
// Forced cancel means the process ignored SIGINT for the cancel
// wall-clock window. We mark the job as cancelled and release the
// lock so the session is reusable; the error message tells the
// caller the process may still be running so subsequent commands
// should be considered carefully. This is consistent: callers see
// completed=true exactly when the lock is no longer held.
job.status = "cancelled";
job.error = result.error;
releaseSessionExecution(sessionId, sessionToken);
return;
}
if (result.error) {
job.status = "failed";
job.error = result.error;
releaseSessionExecution(sessionId, sessionToken);
return;
}
// A non-zero exit code without an error message still represents a
// failed command (e.g. a build/test that returned 1). Mark it as failed
// so callers don't have to special-case exitCode against status.
if (typeof result.exitCode === "number" && result.exitCode !== 0) {
job.status = "failed";
job.error = `Command exited with code ${result.exitCode}`;
releaseSessionExecution(sessionId, sessionToken);
return;
}
job.status = "completed";
releaseSessionExecution(sessionId, sessionToken);
}).catch((err) => {
job.updatedAt = Date.now();
job.status = "failed";
job.error = err?.message || String(err);
storeCompletedJobOutput(job, job.stdout || "");
releaseSessionExecution(sessionId, sessionToken);
});
return {
ok: true,
jobId,
sessionId,
command,
status: "running",
startedAt,
outputMode: "foreground-mirrored",
recommendedPollIntervalMs: DEFAULT_BACKGROUND_JOB_POLL_INTERVAL_MS,
};
}).catch((err) => {
// Probe (or unexpected rejection) must not leave the session lock held.
job.status = "failed";
job.error = err?.message || String(err);
job.updatedAt = Date.now();
job.pendingShellProbe = false;
releaseSessionExecution(sessionId, sessionToken);
return { ok: false, error: err?.message || String(err) };
});
}
function getScopedJob(jobId, chatSessionId) {
const job = backgroundJobs.get(jobId);
if (!job) return null;
// Per-chat isolation: a job started under a chat session can only be
// accessed by callers presenting the same chatSessionId. Unscoped or
// statically-scoped callers cannot reach into another chat's jobs.
if (job.chatSessionId) {
if (!chatSessionId || job.chatSessionId !== chatSessionId) {
return null;
}
}
return job;
}
function handleJobPoll(params) {
const { jobId, offset = 0, chatSessionId, scopedSessionIds } = params || {};
if (!jobId) throw new Error("jobId is required");
const job = getScopedJob(jobId, chatSessionId || null);
if (!job) return { ok: false, error: "Background job not found" };
// Re-check session scope so a caller that lost access to the host
// cannot continue reading output from jobs on that session.
// Covers dynamic (chatSessionId) and static (scopedSessionIds) modes.
if (job.sessionId) {
const scopeErr = validateSessionScope(job.sessionId, chatSessionId || null, scopedSessionIds);
if (scopeErr) return { ok: false, error: scopeErr };
}
return serializeBackgroundJob(job, offset);
}
function handleJobStop(params) {
const { jobId, chatSessionId, scopedSessionIds } = params || {};
if (!jobId) throw new Error("jobId is required");
const job = getScopedJob(jobId, chatSessionId || null);
if (!job) return { ok: false, error: "Background job not found" };
// For statically scoped MCP clients, validate that the job's session is
// within the caller's static scope so a foreign jobId cannot cancel jobs
// outside the caller's allowed sessions. Dynamic chat scope is already
// enforced by getScopedJob (caller's chatSessionId must match the job's),
// and we intentionally do NOT re-check dynamic scope here so jobs can
// still be stopped after workspace membership changes — otherwise the
// session lock would stay held forever.
if (Array.isArray(scopedSessionIds) && job.sessionId) {
if (!scopedSessionIds.includes(job.sessionId)) {
return { ok: false, error: `Session "${job.sessionId}" is not in the current scope.` };
}
}
if (job.status === "running") {
try {
job.handle?.cancel?.();
} catch (err) {
return { ok: false, error: err?.message || String(err) };
}
job.status = "stopping";
job.error = "Cancellation requested";
job.updatedAt = Date.now();
}
return serializeBackgroundJob(job, 0);
}
return {
resolveExecContext,
handleExec,
handleJobStart,
getScopedJob,
handleJobPoll,
handleJobStop,
};
}
}
module.exports = { createExecHandlerApi };

View File

@@ -0,0 +1,117 @@
"use strict";
/**
* Merge two scoped snapshots without letting an older cross-scope copy
* overwrite newer connection state. Metadata revisions are assigned by the
* main-process bridge when a renderer update arrives.
*
* @param {Record<string, unknown> | null | undefined} previous
* @param {Record<string, unknown> | null | undefined} fallback
* @returns {Record<string, unknown> | null}
*/
function mergeRetentionMeta(previous, fallback) {
if (!previous && !fallback) return null;
if (!previous) return fallback && typeof fallback === "object" ? fallback : null;
if (!fallback || typeof fallback !== "object") return previous;
const previousRevision = Number.isSafeInteger(previous._revision) ? previous._revision : 0;
const fallbackRevision = Number.isSafeInteger(fallback._revision) ? fallback._revision : 0;
// Unversioned direct helper inputs retain the historical fallback-wins
// behavior. Bridge-owned metadata is always versioned.
const fallbackIsNewer = fallbackRevision >= previousRevision;
const newer = fallbackIsNewer ? fallback : previous;
const older = fallbackIsNewer ? previous : fallback;
const connected = Object.prototype.hasOwnProperty.call(newer, "connected")
? newer.connected !== false
: older.connected !== false;
return {
...older,
...newer,
hostname: newer.hostname || older.hostname,
label: newer.label || older.label,
os: newer.os || older.os,
username: newer.username || older.username,
protocol: newer.protocol || older.protocol,
shellType: newer.shellType || older.shellType,
deviceType: newer.deviceType || older.deviceType,
hostId: newer.hostId || older.hostId,
hostChain: Array.isArray(newer.hostChain) ? newer.hostChain : older.hostChain,
// Explicit empty arrays in the newer snapshot clear stopped forwards.
activePortForwards: Array.isArray(newer.activePortForwards)
? newer.activePortForwards
: older.activePortForwards,
connected,
...(Math.max(previousRevision, fallbackRevision) > 0
? { _revision: Math.max(previousRevision, fallbackRevision) }
: {}),
};
}
/**
* Keep host_open-owned sessions in a chat scope when a full metadata replace
* would otherwise drop them (e.g. AIChatSidePanel pushing only the current
* terminal tab after a mid-turn host_open).
*
* Empty incoming lists are treated as authoritative clears and are not retained.
*
* @param {{
* incomingSessions: Array<Record<string, unknown>>,
* ownedSessionIds: string[],
* previousById?: Map<string, Record<string, unknown>> | null,
* findFallbackMeta?: ((sessionId: string) => Record<string, unknown> | null | undefined) | null,
* }} args
* @returns {Array<Record<string, unknown>>}
*/
function retainOwnedSessions({
incomingSessions,
ownedSessionIds,
previousById = null,
findFallbackMeta = null,
}) {
if (!Array.isArray(incomingSessions) || incomingSessions.length === 0) {
return incomingSessions;
}
if (!Array.isArray(ownedSessionIds) || ownedSessionIds.length === 0) {
return incomingSessions;
}
const byId = new Map();
for (const entry of incomingSessions) {
if (!entry || typeof entry !== "object" || !entry.sessionId) continue;
byId.set(String(entry.sessionId), entry);
}
for (const ownedIdRaw of ownedSessionIds) {
const ownedId = typeof ownedIdRaw === "string" ? ownedIdRaw.trim() : "";
if (!ownedId || byId.has(ownedId)) continue;
const previous = previousById?.get?.(ownedId) || null;
// Always consult fallback — a stale previous connected:false must not
// block a fresher cross-scope snapshot (e.g. External MCP / other tab).
const fallback = typeof findFallbackMeta === "function"
? findFallbackMeta(ownedId)
: null;
const meta = mergeRetentionMeta(previous, fallback);
if (!meta || typeof meta !== "object") continue;
byId.set(ownedId, {
sessionId: ownedId,
hostname: meta.hostname || "",
label: meta.label || "",
os: meta.os || "",
username: meta.username || "",
protocol: meta.protocol || "",
shellType: meta.shellType || "",
deviceType: meta.deviceType || "",
connected: meta.connected !== false,
hostId: meta.hostId || "",
hostChain: Array.isArray(meta.hostChain) ? meta.hostChain : [],
activePortForwards: Array.isArray(meta.activePortForwards) ? meta.activePortForwards : [],
...(Number.isSafeInteger(meta._revision) ? { _revision: meta._revision } : {}),
});
}
return Array.from(byId.values());
}
module.exports = { retainOwnedSessions, mergeRetentionMeta };

View File

@@ -0,0 +1,267 @@
"use strict";
const test = require("node:test");
const assert = require("node:assert/strict");
const { retainOwnedSessions, mergeRetentionMeta } = require("./retainOwnedSessions.cjs");
test("retainOwnedSessions keeps host_open-owned sessions dropped by a full scope replace", () => {
const previousById = new Map([
["sess-original", {
hostname: "10.0.0.1",
label: "server-a",
connected: true,
hostId: "host-a",
}],
["sess-opened", {
hostname: "10.0.0.2",
label: "server-b",
protocol: "ssh",
connected: false,
hostId: "host-b",
}],
]);
const retained = retainOwnedSessions({
incomingSessions: [{
sessionId: "sess-original",
hostname: "10.0.0.1",
label: "server-a",
connected: true,
hostId: "host-a",
}],
ownedSessionIds: ["sess-opened"],
previousById,
});
const ids = retained.map((entry) => entry.sessionId).sort();
assert.deepEqual(ids, ["sess-opened", "sess-original"]);
const opened = retained.find((entry) => entry.sessionId === "sess-opened");
assert.equal(opened.label, "server-b");
assert.equal(opened.hostId, "host-b");
assert.equal(opened.connected, false);
});
test("retainOwnedSessions does not alter authoritative empty replaces", () => {
const retained = retainOwnedSessions({
incomingSessions: [],
ownedSessionIds: ["sess-opened"],
previousById: new Map([
["sess-opened", { hostname: "10.0.0.2", label: "server-b" }],
]),
});
assert.deepEqual(retained, []);
});
test("retainOwnedSessions falls back to cross-scope metadata when needed", () => {
const retained = retainOwnedSessions({
incomingSessions: [{ sessionId: "sess-original", label: "server-a" }],
ownedSessionIds: ["sess-opened"],
previousById: new Map(),
findFallbackMeta: (sessionId) => (
sessionId === "sess-opened"
? { hostname: "10.0.0.2", label: "server-b", hostId: "host-b" }
: null
),
});
assert.equal(retained.length, 2);
assert.equal(
retained.find((entry) => entry.sessionId === "sess-opened")?.label,
"server-b",
);
});
test("retainOwnedSessions ignores owned ids with no recoverable metadata", () => {
const retained = retainOwnedSessions({
incomingSessions: [{ sessionId: "sess-original", label: "server-a" }],
ownedSessionIds: ["sess-ghost"],
previousById: new Map(),
});
assert.deepEqual(retained.map((entry) => entry.sessionId), ["sess-original"]);
});
test("retainOwnedSessions refreshes connected from fallback even when previous exists", () => {
const retained = retainOwnedSessions({
incomingSessions: [{
sessionId: "sess-original",
hostname: "10.0.0.1",
label: "server-a",
connected: true,
}],
ownedSessionIds: ["sess-opened"],
previousById: new Map([
["sess-opened", {
hostname: "10.0.0.2",
label: "server-b",
connected: false,
hostId: "host-b",
}],
]),
findFallbackMeta: (sessionId) => (
sessionId === "sess-opened"
? {
hostname: "10.0.0.2",
label: "server-b",
connected: true,
hostId: "host-b",
username: "root",
}
: null
),
});
const opened = retained.find((entry) => entry.sessionId === "sess-opened");
assert.ok(opened);
assert.equal(opened.connected, true);
assert.equal(opened.username, "root");
assert.equal(opened.hostId, "host-b");
});
test("mergeRetentionMeta lets a later disconnected fallback replace connected:true", () => {
const merged = mergeRetentionMeta(
{
hostname: "10.0.0.2",
label: "server-b",
connected: true,
hostId: "host-b",
},
{
hostname: "10.0.0.2",
label: "server-b",
connected: false,
hostId: "host-b",
},
);
assert.equal(merged.connected, false);
});
test("retainOwnedSessions applies a later disconnect from fallback metadata", () => {
const retained = retainOwnedSessions({
incomingSessions: [{
sessionId: "sess-original",
hostname: "10.0.0.1",
label: "server-a",
connected: true,
}],
ownedSessionIds: ["sess-opened"],
previousById: new Map([
["sess-opened", {
hostname: "10.0.0.2",
label: "server-b",
connected: true,
hostId: "host-b",
}],
]),
findFallbackMeta: (sessionId) => (
sessionId === "sess-opened"
? {
hostname: "10.0.0.2",
label: "server-b",
connected: false,
hostId: "host-b",
}
: null
),
});
const opened = retained.find((entry) => entry.sessionId === "sess-opened");
assert.ok(opened);
assert.equal(opened.connected, false);
});
test("mergeRetentionMeta clears activePortForwards when fallback reports an empty array", () => {
const merged = mergeRetentionMeta(
{
hostname: "10.0.0.2",
label: "server-b",
connected: true,
activePortForwards: [{ ruleId: "fwd-1", localPort: 8080, status: "active" }],
},
{
hostname: "10.0.0.2",
label: "server-b",
connected: true,
activePortForwards: [],
},
);
assert.deepEqual(merged.activePortForwards, []);
});
test("mergeRetentionMeta keeps prior activePortForwards when fallback omits them", () => {
const prior = [{ ruleId: "fwd-1", localPort: 8080, status: "active" }];
const merged = mergeRetentionMeta(
{
hostname: "10.0.0.2",
label: "server-b",
connected: true,
activePortForwards: prior,
},
{
hostname: "10.0.0.2",
label: "server-b",
connected: true,
},
);
assert.deepEqual(merged.activePortForwards, prior);
});
test("retainOwnedSessions applies empty activePortForwards from fallback metadata", () => {
const retained = retainOwnedSessions({
incomingSessions: [{
sessionId: "sess-original",
hostname: "10.0.0.1",
label: "server-a",
connected: true,
}],
ownedSessionIds: ["sess-opened"],
previousById: new Map([
["sess-opened", {
hostname: "10.0.0.2",
label: "server-b",
connected: false,
hostId: "host-b",
activePortForwards: [{ ruleId: "fwd-1", localPort: 8080, status: "active" }],
}],
]),
findFallbackMeta: (sessionId) => (
sessionId === "sess-opened"
? {
hostname: "10.0.0.2",
label: "server-b",
connected: true,
hostId: "host-b",
activePortForwards: [],
}
: null
),
});
const opened = retained.find((entry) => entry.sessionId === "sess-opened");
assert.ok(opened);
assert.deepEqual(opened.activePortForwards, []);
assert.equal(opened.connected, true);
});
test("mergeRetentionMeta keeps the higher metadata revision in either argument", () => {
const oldConnected = {
connected: true,
username: "old-user",
activePortForwards: [{ ruleId: "old-forward" }],
_revision: 4,
};
const newDisconnected = {
connected: false,
username: "new-user",
activePortForwards: [],
_revision: 7,
};
for (const merged of [
mergeRetentionMeta(oldConnected, newDisconnected),
mergeRetentionMeta(newDisconnected, oldConnected),
]) {
assert.equal(merged.connected, false);
assert.equal(merged.username, "new-user");
assert.deepEqual(merged.activePortForwards, []);
assert.equal(merged._revision, 7);
}
});

View File

@@ -0,0 +1,195 @@
"use strict";
const DEFAULT_SESSION_IDLE_TIMEOUT_MINUTES = 30;
const MIN_SESSION_IDLE_TIMEOUT_MINUTES = 1;
const MAX_SESSION_IDLE_TIMEOUT_MINUTES = 24 * 60;
function normalizeSessionIdleTimeoutMinutes(value) {
const parsed = Number(value);
if (!Number.isFinite(parsed)) return DEFAULT_SESSION_IDLE_TIMEOUT_MINUTES;
return Math.min(
MAX_SESSION_IDLE_TIMEOUT_MINUTES,
Math.max(MIN_SESSION_IDLE_TIMEOUT_MINUTES, Math.round(parsed)),
);
}
function createSessionIdleManager(options = {}) {
const DateImpl = options.Date || Date;
const setTimeoutImpl = options.setTimeout || setTimeout;
const clearTimeoutImpl = options.clearTimeout || clearTimeout;
const onIdle = typeof options.onIdle === "function" ? options.onIdle : async () => {};
const entries = new Map();
let timeoutMinutes = normalizeSessionIdleTimeoutMinutes(options.timeoutMinutes);
function clearTimer(entry) {
if (entry.timer == null) return;
clearTimeoutImpl(entry.timer);
entry.timer = null;
}
function schedule(entry) {
clearTimer(entry);
if (entry.activeCount > 0 || entry.checking || entry.closing) return;
const expiresAt = entry.lastActivityAt + timeoutMinutes * 60 * 1000;
const delay = Math.max(0, expiresAt - DateImpl.now());
entry.timer = setTimeoutImpl(() => {
entry.timer = null;
const current = entries.get(entry.sessionId);
if (current !== entry || entry.activeCount > 0 || entry.checking || entry.closing) return;
const latestExpiresAt = entry.lastActivityAt + timeoutMinutes * 60 * 1000;
if (DateImpl.now() < latestExpiresAt) {
schedule(entry);
return;
}
entry.checking = true;
const activityVersion = entry.activityVersion;
Promise.resolve(onIdle({
chatSessionId: entry.chatSessionId,
sessionId: entry.sessionId,
}, activityVersion)).catch(() => {
resume(entry.sessionId);
});
}, delay);
entry.timer?.unref?.();
}
function track(chatSessionId, sessionId) {
if (!chatSessionId || !sessionId) return false;
const existing = entries.get(sessionId);
if (existing) clearTimer(existing);
const entry = {
chatSessionId,
sessionId,
lastActivityAt: DateImpl.now(),
activityVersion: 0,
activeCount: 0,
checking: false,
closing: false,
timer: null,
};
entries.set(sessionId, entry);
schedule(entry);
return true;
}
function touch(chatSessionId, sessionId) {
const entry = entries.get(sessionId);
if (!entry || entry.closing) return false;
entry.checking = false;
entry.activityVersion += 1;
entry.lastActivityAt = DateImpl.now();
// Keep an existing timer instead of recreating it for every output chunk.
// Its callback rechecks lastActivityAt and extends the deadline as needed.
if (entry.timer == null) schedule(entry);
return true;
}
function beginActivity(chatSessionId, sessionId) {
const entry = entries.get(sessionId);
if (!entry || entry.closing) return false;
entry.checking = false;
entry.activityVersion += 1;
entry.activeCount += 1;
entry.lastActivityAt = DateImpl.now();
clearTimer(entry);
return true;
}
function endActivity(chatSessionId, sessionId) {
const entry = entries.get(sessionId);
if (!entry || entry.closing) return false;
entry.activityVersion += 1;
entry.activeCount = Math.max(0, entry.activeCount - 1);
entry.lastActivityAt = DateImpl.now();
schedule(entry);
return true;
}
function beginClose(sessionId) {
const entry = entries.get(sessionId);
if (!entry || entry.closing) return false;
entry.checking = false;
entry.closing = true;
clearTimer(entry);
return true;
}
function beginIdleClose(sessionId, activityVersion) {
if (!isIdleCheckCurrent(sessionId, activityVersion)) return false;
return beginClose(sessionId);
}
function isIdleCheckCurrent(sessionId, activityVersion) {
const entry = entries.get(sessionId);
return Boolean(
entry
&& entry.checking
&& !entry.closing
&& entry.activeCount === 0
&& entry.activityVersion === activityVersion
);
}
function resume(sessionId) {
const entry = entries.get(sessionId);
if (!entry) return false;
entry.closing = false;
entry.checking = false;
entry.activityVersion += 1;
entry.activeCount = 0;
entry.lastActivityAt = DateImpl.now();
schedule(entry);
return true;
}
function forgetSession(sessionId) {
const entry = entries.get(sessionId);
if (!entry) return false;
clearTimer(entry);
entries.delete(sessionId);
return true;
}
function setTimeoutMinutes(value) {
timeoutMinutes = normalizeSessionIdleTimeoutMinutes(value);
for (const entry of entries.values()) schedule(entry);
return timeoutMinutes;
}
function clearAll() {
for (const entry of entries.values()) clearTimer(entry);
entries.clear();
}
function scopeCleared() {
// Intentionally keep timers alive. A deleted/interrupted AI scope is one of
// the cases where the idle fallback is needed most.
}
return {
track,
touch,
beginActivity,
endActivity,
beginClose,
beginIdleClose,
resume,
forgetSession,
isTracked: (sessionId) => entries.has(sessionId),
hasActivity: (sessionId) => Boolean(entries.get(sessionId)?.activeCount),
isIdleCheckCurrent,
isClosing: (sessionId) => Boolean(entries.get(sessionId)?.closing),
setTimeoutMinutes,
getTimeoutMinutes: () => timeoutMinutes,
clearAll,
scopeCleared,
};
}
module.exports = {
DEFAULT_SESSION_IDLE_TIMEOUT_MINUTES,
MIN_SESSION_IDLE_TIMEOUT_MINUTES,
MAX_SESSION_IDLE_TIMEOUT_MINUTES,
normalizeSessionIdleTimeoutMinutes,
createSessionIdleManager,
};

View File

@@ -0,0 +1,227 @@
"use strict";
const test = require("node:test");
const assert = require("node:assert/strict");
const { createSessionIdleManager } = require("./sessionIdleManager.cjs");
function createClock() {
let now = 0;
let nextId = 1;
const timers = new Map();
function runDueTimers() {
while (true) {
const due = Array.from(timers.entries())
.filter(([, timer]) => timer.at <= now)
.sort((a, b) => a[1].at - b[1].at)[0];
if (!due) break;
timers.delete(due[0]);
due[1].callback();
}
}
return {
Date: { now: () => now },
setTimeout(callback, delay) {
const id = nextId++;
timers.set(id, { callback, at: now + delay });
return id;
},
clearTimeout(id) {
timers.delete(id);
},
advance(ms) {
now += ms;
runDueTimers();
},
timerCount() {
return timers.size;
},
};
}
test("idle sessions close independently and activity renews only the matching session", async () => {
const clock = createClock();
const closed = [];
const manager = createSessionIdleManager({
...clock,
timeoutMinutes: 1,
onIdle: async (entry) => closed.push(entry),
});
manager.track("chat-a", "session-1");
manager.track("chat-a", "session-2");
clock.advance(30_000);
await Promise.resolve();
assert.deepEqual(closed, []);
manager.touch("chat-a", "session-1");
clock.advance(30_000);
await Promise.resolve();
assert.deepEqual(closed, [{ chatSessionId: "chat-a", sessionId: "session-2" }]);
clock.advance(30_000);
await Promise.resolve();
assert.deepEqual(closed, [
{ chatSessionId: "chat-a", sessionId: "session-2" },
{ chatSessionId: "chat-a", sessionId: "session-1" },
]);
});
test("an in-flight operation cannot time out and gets a fresh idle window when it ends", async () => {
const clock = createClock();
const closed = [];
const manager = createSessionIdleManager({
...clock,
timeoutMinutes: 1,
onIdle: async (entry) => closed.push(entry),
});
manager.track("chat-a", "session-1");
assert.equal(manager.beginActivity("chat-a", "session-1"), true);
clock.advance(120_000);
await Promise.resolve();
assert.deepEqual(closed, []);
assert.equal(clock.timerCount(), 0);
manager.endActivity("chat-a", "session-1");
clock.advance(59_999);
await Promise.resolve();
assert.deepEqual(closed, []);
clock.advance(1);
await Promise.resolve();
assert.deepEqual(closed, [{ chatSessionId: "chat-a", sessionId: "session-1" }]);
});
test("activity from another authorized scope renews the same terminal session", async () => {
const clock = createClock();
const closed = [];
const manager = createSessionIdleManager({
...clock,
timeoutMinutes: 1,
onIdle: async (entry) => closed.push(entry),
});
manager.track("chat-owner", "session-1");
clock.advance(30_000);
assert.equal(manager.beginActivity("chat-other", "session-1"), true);
manager.endActivity("chat-other", "session-1");
clock.advance(59_999);
await Promise.resolve();
assert.deepEqual(closed, []);
clock.advance(1);
await Promise.resolve();
assert.deepEqual(closed, [{ chatSessionId: "chat-owner", sessionId: "session-1" }]);
});
test("activity starting while an idle check is pending prevents the close", async () => {
const clock = createClock();
const closed = [];
let releaseIdleCheck;
const idleCheck = new Promise((resolve) => {
releaseIdleCheck = resolve;
});
let manager;
manager = createSessionIdleManager({
...clock,
timeoutMinutes: 1,
onIdle: async (entry) => {
await idleCheck;
if (!manager.hasActivity(entry.sessionId)) closed.push(entry);
},
});
manager.track("chat-owner", "session-1");
clock.advance(60_000);
assert.equal(manager.beginActivity("chat-other", "session-1"), true);
releaseIdleCheck();
await idleCheck;
await Promise.resolve();
assert.deepEqual(closed, []);
manager.endActivity("chat-other", "session-1");
clock.advance(60_000);
await Promise.resolve();
assert.deepEqual(closed, [{ chatSessionId: "chat-owner", sessionId: "session-1" }]);
});
test("activity that starts and ends during an idle check invalidates the stale check", async () => {
const clock = createClock();
const closed = [];
let releaseIdleCheck;
const idleCheck = new Promise((resolve) => {
releaseIdleCheck = resolve;
});
let manager;
manager = createSessionIdleManager({
...clock,
timeoutMinutes: 1,
onIdle: async (entry, activityVersion) => {
await idleCheck;
if (manager.beginIdleClose(entry.sessionId, activityVersion)) {
closed.push(entry);
}
},
});
manager.track("chat-owner", "session-1");
clock.advance(60_000);
assert.equal(manager.beginActivity("chat-other", "session-1"), true);
assert.equal(manager.endActivity("chat-other", "session-1"), true);
releaseIdleCheck();
await idleCheck;
await Promise.resolve();
assert.deepEqual(closed, []);
clock.advance(59_999);
await Promise.resolve();
assert.deepEqual(closed, []);
clock.advance(1);
await Promise.resolve();
assert.deepEqual(closed, [{ chatSessionId: "chat-owner", sessionId: "session-1" }]);
});
test("a failed close resumes tracking while a successful close can be forgotten", async () => {
const clock = createClock();
let attempts = 0;
const manager = createSessionIdleManager({
...clock,
timeoutMinutes: 1,
onIdle: async ({ sessionId }) => {
attempts += 1;
if (attempts === 1) manager.resume(sessionId);
else manager.forgetSession(sessionId);
},
});
manager.track("chat-a", "session-1");
clock.advance(60_000);
await Promise.resolve();
assert.equal(attempts, 1);
assert.equal(manager.isTracked("session-1"), true);
clock.advance(60_000);
await Promise.resolve();
assert.equal(attempts, 2);
assert.equal(manager.isTracked("session-1"), false);
assert.equal(clock.timerCount(), 0);
});
test("clearing an AI scope does not discard its idle cleanup fallback", async () => {
const clock = createClock();
const closed = [];
const manager = createSessionIdleManager({
...clock,
timeoutMinutes: 1,
onIdle: async (entry) => closed.push(entry),
});
manager.track("chat-a", "session-1");
manager.scopeCleared("chat-a");
clock.advance(60_000);
await Promise.resolve();
assert.deepEqual(closed, [{ chatSessionId: "chat-a", sessionId: "session-1" }]);
});

View File

@@ -0,0 +1,92 @@
"use strict";
function createSessionOwnershipRegistry() {
const ownedByScope = new Map();
const scopeGenerations = new Map();
function captureGeneration(chatSessionId) {
if (!chatSessionId) return null;
let generation = scopeGenerations.get(chatSessionId);
if (!generation) {
generation = { chatSessionId, revoked: false };
scopeGenerations.set(chatSessionId, generation);
}
return generation;
}
function register(chatSessionId, sessionId, expectedGeneration = null) {
if (!chatSessionId || !sessionId) return false;
if (expectedGeneration !== null) {
const currentGeneration = scopeGenerations.get(chatSessionId);
if (
expectedGeneration.revoked
|| expectedGeneration.chatSessionId !== chatSessionId
|| currentGeneration !== expectedGeneration
) {
return false;
}
}
const owned = ownedByScope.get(chatSessionId) || new Set();
owned.add(sessionId);
ownedByScope.set(chatSessionId, owned);
return true;
}
function validate(chatSessionId, sessionId) {
if (!chatSessionId) return { ok: false, error: "chatSessionId is required." };
if (!ownedByScope.get(chatSessionId)?.has(sessionId)) {
return {
ok: false,
error: `Session "${sessionId}" was not opened by the current AI scope.`,
};
}
return { ok: true };
}
function listOwned(chatSessionId) {
if (!chatSessionId) return [];
const owned = ownedByScope.get(chatSessionId);
return owned ? Array.from(owned) : [];
}
function forgetSession(sessionId) {
for (const [scopeId, owned] of ownedByScope) {
owned.delete(sessionId);
if (owned.size === 0) ownedByScope.delete(scopeId);
}
}
/**
* Drop retained ownership for a chat scope without revoking its host_open
* generation. Used when the renderer pushes an authoritative empty scope
* replace so a later non-empty sync cannot resurrect cleared sessions.
*/
function releaseScopeOwnership(chatSessionId) {
if (!chatSessionId) return;
ownedByScope.delete(chatSessionId);
}
function clearScope(chatSessionId) {
ownedByScope.delete(chatSessionId);
const generation = scopeGenerations.get(chatSessionId);
if (generation) generation.revoked = true;
scopeGenerations.delete(chatSessionId);
}
function getTrackedGenerationCountForTests() {
return scopeGenerations.size;
}
return {
captureGeneration,
register,
validate,
listOwned,
forgetSession,
releaseScopeOwnership,
clearScope,
getTrackedGenerationCountForTests,
};
}
module.exports = { createSessionOwnershipRegistry };

View File

@@ -0,0 +1,90 @@
"use strict";
const test = require("node:test");
const assert = require("node:assert/strict");
const { createSessionOwnershipRegistry } = require("./sessionOwnership.cjs");
test("session ownership is isolated by AI scope", () => {
const ownership = createSessionOwnershipRegistry();
ownership.register("chat-a", "session-1");
assert.equal(ownership.validate("chat-a", "session-1").ok, true);
assert.equal(ownership.validate("chat-b", "session-1").ok, false);
assert.match(ownership.validate("chat-b", "session-1").error, /not opened/i);
});
test("listOwned returns sessions registered for a chat scope", () => {
const ownership = createSessionOwnershipRegistry();
ownership.register("chat-a", "session-1");
ownership.register("chat-a", "session-2");
ownership.register("chat-b", "session-3");
assert.deepEqual(ownership.listOwned("chat-a").sort(), ["session-1", "session-2"]);
assert.deepEqual(ownership.listOwned("chat-b"), ["session-3"]);
assert.deepEqual(ownership.listOwned("missing"), []);
assert.deepEqual(ownership.listOwned(""), []);
});
test("forgetSession revokes ownership from every scope", () => {
const ownership = createSessionOwnershipRegistry();
ownership.register("chat-a", "session-1");
ownership.register("chat-b", "session-1");
ownership.forgetSession("session-1");
assert.equal(ownership.validate("chat-a", "session-1").ok, false);
assert.equal(ownership.validate("chat-b", "session-1").ok, false);
});
test("releaseScopeOwnership clears retained ids without revoking generations", () => {
const ownership = createSessionOwnershipRegistry();
const generation = ownership.captureGeneration("chat-a");
ownership.register("chat-a", "session-1", generation);
ownership.register("chat-b", "session-2");
ownership.releaseScopeOwnership("chat-a");
assert.deepEqual(ownership.listOwned("chat-a"), []);
assert.equal(ownership.validate("chat-a", "session-1").ok, false);
assert.equal(ownership.validate("chat-b", "session-2").ok, true);
// Late host_open for the same generation must still be allowed.
assert.equal(ownership.register("chat-a", "session-3", generation), true);
assert.deepEqual(ownership.listOwned("chat-a"), ["session-3"]);
});
test("clearScope only revokes the deleted chat scope", () => {
const ownership = createSessionOwnershipRegistry();
ownership.register("chat-a", "session-1");
ownership.register("chat-b", "session-2");
ownership.clearScope("chat-a");
assert.equal(ownership.validate("chat-a", "session-1").ok, false);
assert.equal(ownership.validate("chat-b", "session-2").ok, true);
});
test("a host open that finishes after scope cleanup cannot restore ownership", () => {
const ownership = createSessionOwnershipRegistry();
const generation = ownership.captureGeneration("chat-a");
ownership.clearScope("chat-a");
assert.equal(ownership.register("chat-a", "session-1", generation), false);
assert.equal(ownership.validate("chat-a", "session-1").ok, false);
const nextGeneration = ownership.captureGeneration("chat-a");
assert.equal(ownership.register("chat-a", "session-2", nextGeneration), true);
});
test("cleared scope generations are released without allowing a late host open", () => {
const ownership = createSessionOwnershipRegistry();
const staleGenerations = [];
for (let index = 0; index < 100; index += 1) {
const scopeId = `chat-${index}`;
staleGenerations.push([scopeId, ownership.captureGeneration(scopeId)]);
ownership.clearScope(scopeId);
}
assert.equal(ownership.getTrackedGenerationCountForTests(), 0);
for (const [scopeId, generation] of staleGenerations) {
assert.equal(ownership.register(scopeId, `late-${scopeId}`, generation), false);
}
});

View File

@@ -0,0 +1,545 @@
/* eslint-disable no-undef */
function createSftpHandlerApi(ctx) {
with (ctx) {
function getSessionSftpEncodingStateKey(chatSessionId, sessionId) {
if (!chatSessionId || !sessionId) return null;
return `chat:${chatSessionId}:session:${sessionId}`;
}
function getWorkerManager() {
return typeof terminalWorkerManager !== "undefined" ? terminalWorkerManager : null;
}
function getMainSessions() {
return typeof sessions !== "undefined" ? sessions : null;
}
function getStableTransferHostId(params) {
if (typeof params?.hostId === "string" && params.hostId) return params.hostId;
const mainHostId = getMainSessions()?.get?.(params?.sessionId)?.hostId;
if (typeof mainHostId === "string" && mainHostId) return mainHostId;
const workerHostId = getWorkerManager()?.getSessionHostId?.(params?.sessionId);
if (typeof workerHostId === "string" && workerHostId) return workerHostId;
return typeof params?.sessionId === "string" && params.sessionId ? params.sessionId : undefined;
}
function shouldProxySessionBackedSftpToWorker(params) {
if (!params?.sessionId) return false;
const manager = getWorkerManager();
if (!manager?.request) return false;
const mainSessions = getMainSessions();
return !mainSessions?.get?.(params.sessionId);
}
function waitForWorkerSftpRequest(requestPromise, options = {}) {
const timeoutMs = Number.isFinite(options.timeoutMs) && options.timeoutMs > 0
? options.timeoutMs
: 0;
if (!timeoutMs) return requestPromise;
let timer = null;
const timeoutPromise = new Promise((_, reject) => {
timer = setTimeout(() => {
reject(new Error(`${options.operationName || "SFTP operation"} timed out after ${timeoutMs}ms`));
}, timeoutMs);
});
return Promise.race([requestPromise, timeoutPromise]).finally(() => {
if (timer) clearTimeout(timer);
});
}
function requestWorkerSftp(channel, payload, options = {}) {
const manager = getWorkerManager();
if (!manager?.request) {
return Promise.reject(new Error("Terminal worker is unavailable"));
}
return waitForWorkerSftpRequest(manager.request(channel, payload, {}), options);
}
async function withWorkerSessionBackedSftp(params, workerChannel, options = {}) {
if (!workerChannel) throw new Error("Worker SFTP channel is required");
const chatSessionId = typeof params?.chatSessionId === "string" && params.chatSessionId ? params.chatSessionId : null;
const encodingStateKey = getSessionSftpEncodingStateKey(chatSessionId, params.sessionId);
const timeoutMs = Number.isFinite(options.timeoutMs) && options.timeoutMs > 0 ? options.timeoutMs : 0;
const operationName = options.operationName || "SFTP operation";
let sftpId = null;
let pendingOpenPromise = null;
let boundedOpenPromise = null;
let closePromise = null;
let closeRequested = false;
let cancellationError = null;
const closeKnownSftpHandle = () => {
if (!sftpId) return Promise.resolve();
if (!closePromise) {
closePromise = requestWorkerSftp("netcatty:sftp:close", { sftpId, encodingStateKey });
}
return closePromise;
};
const closeSftpHandle = async () => {
closeRequested = true;
if (!sftpId && boundedOpenPromise) {
try {
const opened = await boundedOpenPromise;
sftpId = opened?.sftpId || null;
} catch {
// Do not let an unresponsive worker open block cancellation. If it
// eventually succeeds, the late-result handler below closes it.
}
}
return closeKnownSftpHandle();
};
const unregisterSftpOp = registerSftpOp(chatSessionId, params.sessionId, () => {
if (!cancellationError) {
cancellationError = new Error("Cancelled");
}
return closeSftpHandle().catch(() => {
// Ignore close failures while cancelling a worker-backed SFTP handle.
});
});
try {
const manager = getWorkerManager();
pendingOpenPromise = manager.request("netcatty:sftp:openForSession", {
sessionId: params.sessionId,
encodingStateKey,
timeoutMs,
}, {});
boundedOpenPromise = waitForWorkerSftpRequest(pendingOpenPromise, { timeoutMs, operationName });
void pendingOpenPromise.then((lateOpened) => {
if (!sftpId) sftpId = lateOpened?.sftpId || null;
if (closeRequested) return closeKnownSftpHandle();
return undefined;
}).catch(() => {
// The bounded request reports open failures to the active operation.
});
const opened = await boundedOpenPromise;
sftpId = opened?.sftpId;
if (!sftpId) throw new Error("Failed to open session-backed SFTP handle");
if (cancellationError) throw cancellationError;
const { abortSignal: _abortSignal, ...workerParams } = params || {};
const workerPayload = options.buildWorkerPayload
? options.buildWorkerPayload(workerParams, sftpId)
: { ...workerParams, sftpId, timeoutMs };
const value = await requestWorkerSftp(workerChannel, workerPayload, { timeoutMs, operationName });
if (cancellationError) throw cancellationError;
return value;
} finally {
unregisterSftpOp();
try {
await closeSftpHandle();
} catch {
// Ignore close failures for one-off worker-backed SFTP handles.
}
}
}
async function withSessionBackedSftp(params, action, options = {}) {
if (!params?.sessionId) throw new Error("sessionId is required");
if (shouldProxySessionBackedSftpToWorker(params) && options.workerChannel) {
return withWorkerSessionBackedSftp(params, options.workerChannel, options);
}
const chatSessionId = typeof params?.chatSessionId === "string" && params.chatSessionId ? params.chatSessionId : null;
const encodingStateKey = getSessionSftpEncodingStateKey(chatSessionId, params.sessionId);
const timeoutMs = Number.isFinite(options.timeoutMs) && options.timeoutMs > 0 ? options.timeoutMs : 0;
const cancelCleanupGraceMs = Number.isFinite(options.cancelCleanupGraceMs) && options.cancelCleanupGraceMs >= 0
? options.cancelCleanupGraceMs
: 1000;
const operationName = options.operationName || "SFTP operation";
const abortController = new AbortController();
let sftpId = null;
let timeoutId = null;
let forceCloseTimer = null;
let closeRequested = false;
let closePromise = null;
let cancellationError = null;
let timeoutError = null;
const closeSftpHandle = () => {
if (!sftpId) {
return Promise.resolve();
}
if (!closePromise) {
closePromise = Promise.resolve().then(() => sftpBridge.closeSftp(null, { sftpId, encodingStateKey }));
}
return closePromise;
};
const closeSftpInBackground = () => {
if (closeRequested) return;
closeRequested = true;
void closeSftpHandle().catch(() => {
// Ignore close failures while cleaning up a cancelled or timed-out handle.
});
};
const requestAbort = (err) => {
if (!abortController.signal.aborted) {
abortController.abort(err);
}
if (!forceCloseTimer && !closeRequested) {
forceCloseTimer = setTimeout(() => {
forceCloseTimer = null;
closeSftpInBackground();
}, cancelCleanupGraceMs);
}
};
const unregisterSftpOp = registerSftpOp(chatSessionId, params.sessionId, () => {
if (!cancellationError) {
cancellationError = new Error("Cancelled");
}
requestAbort(cancellationError);
closeRequested = true;
return closeSftpHandle().catch(() => {
// Ignore close failures while cancelling the SFTP operation.
});
});
try {
if (timeoutMs) {
timeoutId = setTimeout(() => {
if (!timeoutError) {
timeoutError = new Error(`${operationName} timed out after ${timeoutMs}ms`);
}
requestAbort(timeoutError);
}, timeoutMs);
}
const opened = await sftpBridge.openSftpForSession(null, {
sessionId: params.sessionId,
encodingStateKey,
abortSignal: abortController.signal,
timeoutMs,
});
sftpId = opened?.sftpId;
if (!sftpId) throw new Error("Failed to open session-backed SFTP handle");
if (timeoutError) {
throw timeoutError;
}
if (cancellationError) {
throw cancellationError;
}
const payload = {
...params,
sftpId,
abortSignal: abortController.signal,
timeoutMs,
};
const value = await Promise.resolve().then(() => action(payload));
if (timeoutError) {
throw timeoutError;
}
if (cancellationError) {
throw cancellationError;
}
return value;
} catch (err) {
if (timeoutError) {
throw timeoutError;
}
if (cancellationError) {
throw cancellationError;
}
throw err;
} finally {
unregisterSftpOp();
if (timeoutId) clearTimeout(timeoutId);
if (forceCloseTimer) {
clearTimeout(forceCloseTimer);
forceCloseTimer = null;
}
try {
await closeSftpHandle();
} catch {
// Ignore close failures for one-off internal SFTP handles.
}
}
}
async function handleSftpList(params) {
const entries = await withSessionBackedSftp(
params,
(payload) => sftpBridge.listSftp(null, payload),
{ timeoutMs: commandTimeoutMs, operationName: "SFTP list", workerChannel: "netcatty:sftp:list" },
);
return { ok: true, entries };
}
async function handleSftpRead(params) {
if (!params?.path) throw new Error("path is required");
const content = await withSessionBackedSftp(
params,
(payload) => sftpBridge.readSftp(null, payload),
{ timeoutMs: commandTimeoutMs, operationName: "SFTP read", workerChannel: "netcatty:sftp:read" },
);
return { ok: true, path: params.path, content };
}
async function handleSftpWrite(params) {
if (!params?.path) throw new Error("path is required");
if (typeof params?.content !== "string") throw new Error("content is required");
await withSessionBackedSftp(
params,
(payload) => sftpBridge.writeSftp(null, payload),
{ timeoutMs: commandTimeoutMs, operationName: "SFTP write", workerChannel: "netcatty:sftp:write" },
);
return { ok: true, path: params.path };
}
async function handleSftpDownload(params) {
if (!params?.remotePath || !params?.localPath) {
throw new Error("remotePath and localPath are required");
}
const transferId = createTransferId();
const sourceHostId = getStableTransferHostId(params);
reportTransferEvent({
type: "queued", transferId, origin: "agent", background: true,
direction: "download", sourcePath: params.remotePath, targetPath: params.localPath,
sessionId: params.sessionId, startedAt: Date.now(),
sourceHostId,
});
try {
const sender = {
send(channel, payload) {
if (channel === "netcatty:transfer:progress") {
reportTransferEvent({ type: "progress", ...payload });
} else if (channel === "netcatty:transfer:started") {
reportTransferEvent({ type: "started", ...payload });
} else if (channel === "netcatty:transfer:queued") {
reportTransferEvent({ type: "queued", ...payload });
} else if (channel === "netcatty:transfer:paused") {
reportTransferEvent({ type: "paused", ...payload });
} else if (channel === "netcatty:transfer:cancelled") {
reportTransferEvent({ type: "cancelled", ...payload, endedAt: Date.now() });
}
},
};
const useOuterAdmission = !!(
shouldProxySessionBackedSftpToWorker(params) && transferBridge?.runAdmittedTransfer
);
const runDownload = (skipAdmission) => withSessionBackedSftp(
params,
(payload) => transferBridge
? transferBridge.startTransfer({ sender }, {
transferId,
sourcePath: params.remotePath,
targetPath: params.localPath,
sourceType: "sftp",
targetType: "local",
sourceSftpId: payload.sftpId,
sourceHostId,
resumable: true,
globalConcurrency: transferBridge.getGlobalTransferConcurrency?.(),
// Only skip when outer runAdmittedTransfer already owns the slot.
skipAdmission: skipAdmission === true,
})
: sftpBridge.downloadSftpToLocal(null, payload),
{
timeoutMs: commandTimeoutMs,
operationName: "SFTP download",
workerChannel: transferBridge ? "netcatty:transfer:start" : "netcatty:sftp:downloadToLocal",
buildWorkerPayload: transferBridge ? (_workerParams, sftpId) => ({
transferId,
sourcePath: params.remotePath,
targetPath: params.localPath,
sourceType: "sftp",
targetType: "local",
sourceSftpId: sftpId,
sourceHostId,
resumable: true,
skipAdmission: true,
}) : undefined,
},
);
const result = await (
useOuterAdmission
? transferBridge.runAdmittedTransfer(
{ sender },
{ transferId, sourceHostId, globalConcurrency: transferBridge.getGlobalTransferConcurrency?.() },
undefined,
() => runDownload(true),
)
: runDownload(false)
);
if (result?.cancelled || result?.error === "Transfer cancelled") {
reportTransferEvent({ type: "cancelled", transferId, endedAt: Date.now() });
return { ok: false, cancelled: true, transferId };
}
if (result?.error) throw new Error(result.error);
reportTransferEvent({ type: "completed", transferId, endedAt: Date.now() });
return { ok: true, transferId, ...result };
} catch (error) {
reportTransferEvent({ type: "failed", transferId, endedAt: Date.now(), error: error?.message || String(error) });
throw error;
}
}
async function handleSftpUpload(params) {
if (!params?.remotePath || !params?.localPath) {
throw new Error("remotePath and localPath are required");
}
const transferId = createTransferId();
const targetHostId = getStableTransferHostId(params);
reportTransferEvent({
type: "queued", transferId, origin: "agent", background: true,
direction: "upload", sourcePath: params.localPath, targetPath: params.remotePath,
sessionId: params.sessionId, startedAt: Date.now(),
targetHostId,
});
try {
const sender = {
send(channel, payload) {
if (channel === "netcatty:transfer:progress") {
reportTransferEvent({ type: "progress", ...payload });
} else if (channel === "netcatty:transfer:started") {
reportTransferEvent({ type: "started", ...payload });
} else if (channel === "netcatty:transfer:queued") {
reportTransferEvent({ type: "queued", ...payload });
} else if (channel === "netcatty:transfer:paused") {
reportTransferEvent({ type: "paused", ...payload });
} else if (channel === "netcatty:transfer:cancelled") {
reportTransferEvent({ type: "cancelled", ...payload, endedAt: Date.now() });
}
},
};
const useOuterAdmissionUpload = !!(
shouldProxySessionBackedSftpToWorker(params) && transferBridge?.runAdmittedTransfer
);
const runUpload = (skipAdmission) => withSessionBackedSftp(
params,
(payload) => transferBridge
? transferBridge.startTransfer({ sender }, {
transferId,
sourcePath: params.localPath,
targetPath: params.remotePath,
sourceType: "local",
targetType: "sftp",
targetSftpId: payload.sftpId,
targetHostId,
resumable: true,
globalConcurrency: transferBridge.getGlobalTransferConcurrency?.(),
// Only skip when outer runAdmittedTransfer already owns the slot.
skipAdmission: skipAdmission === true,
})
: sftpBridge.uploadLocalToSftp(null, payload),
{
timeoutMs: commandTimeoutMs,
operationName: "SFTP upload",
workerChannel: transferBridge ? "netcatty:transfer:start" : "netcatty:sftp:uploadLocal",
buildWorkerPayload: transferBridge ? (_workerParams, sftpId) => ({
transferId,
sourcePath: params.localPath,
targetPath: params.remotePath,
sourceType: "local",
targetType: "sftp",
targetSftpId: sftpId,
targetHostId,
resumable: true,
skipAdmission: true,
}) : undefined,
},
);
const result = await (
useOuterAdmissionUpload
? transferBridge.runAdmittedTransfer(
{ sender },
{ transferId, targetHostId, globalConcurrency: transferBridge.getGlobalTransferConcurrency?.() },
undefined,
() => runUpload(true),
)
: runUpload(false)
);
if (result?.cancelled || result?.error === "Transfer cancelled") {
reportTransferEvent({ type: "cancelled", transferId, endedAt: Date.now() });
return { ok: false, cancelled: true, transferId };
}
if (result?.error) throw new Error(result.error);
reportTransferEvent({ type: "completed", transferId, endedAt: Date.now() });
return { ok: true, transferId, ...result };
} catch (error) {
reportTransferEvent({ type: "failed", transferId, endedAt: Date.now(), error: error?.message || String(error) });
throw error;
}
}
async function handleSftpMkdir(params) {
if (!params?.path) throw new Error("path is required");
await withSessionBackedSftp(
params,
(payload) => sftpBridge.mkdirSftp(null, payload),
{ timeoutMs: commandTimeoutMs, operationName: "SFTP mkdir", workerChannel: "netcatty:sftp:mkdir" },
);
return { ok: true, path: params.path };
}
async function handleSftpDelete(params) {
if (!params?.path) throw new Error("path is required");
await withSessionBackedSftp(
params,
(payload) => sftpBridge.deleteSftp(null, payload),
{ timeoutMs: commandTimeoutMs, operationName: "SFTP delete", workerChannel: "netcatty:sftp:delete" },
);
return { ok: true, path: params.path };
}
async function handleSftpRename(params) {
if (!params?.oldPath || !params?.newPath) {
throw new Error("oldPath and newPath are required");
}
await withSessionBackedSftp(
params,
(payload) => sftpBridge.renameSftp(null, payload),
{ timeoutMs: commandTimeoutMs, operationName: "SFTP rename", workerChannel: "netcatty:sftp:rename" },
);
return { ok: true, oldPath: params.oldPath, newPath: params.newPath };
}
async function handleSftpStat(params) {
if (!params?.path) throw new Error("path is required");
const stat = await withSessionBackedSftp(
params,
(payload) => sftpBridge.statSftp(null, payload),
{ timeoutMs: commandTimeoutMs, operationName: "SFTP stat", workerChannel: "netcatty:sftp:stat" },
);
return { ok: true, stat };
}
async function handleSftpChmod(params) {
if (!params?.path || !params?.mode) throw new Error("path and mode are required");
await withSessionBackedSftp(
params,
(payload) => sftpBridge.chmodSftp(null, payload),
{ timeoutMs: commandTimeoutMs, operationName: "SFTP chmod", workerChannel: "netcatty:sftp:chmod" },
);
return { ok: true, path: params.path, mode: params.mode };
}
async function handleSftpHome(params) {
const result = await withSessionBackedSftp(
params,
(payload) => sftpBridge.getSftpHomeDir(null, payload),
{ timeoutMs: commandTimeoutMs, operationName: "SFTP home", workerChannel: "netcatty:sftp:homeDir" },
);
if (!result?.success) {
throw new Error(result?.error || "Could not determine home directory");
}
return { ok: true, homeDir: result.homeDir };
}
return {
getSessionSftpEncodingStateKey,
withSessionBackedSftp,
handleSftpList,
handleSftpRead,
handleSftpWrite,
handleSftpDownload,
handleSftpUpload,
handleSftpMkdir,
handleSftpDelete,
handleSftpRename,
handleSftpStat,
handleSftpChmod,
handleSftpHome,
};
}
}
module.exports = { createSftpHandlerApi };