[Init] Initial commit - NetMesh terminal manager
Some checks failed
build-packages / resolve bundled mosh-client (push) Has been cancelled
build-packages / resolve bundled et-client (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
build-packages / update Nix release metadata (push) Has been cancelled
build-packages / bump homebrew tap (push) Has been cancelled
test / lint-and-test (push) Has been cancelled
AI automation / Route event (push) Has been cancelled
AI automation / Hand reopened issue to maintainers (push) Has been cancelled
AI automation / Clean source issue state (push) Has been cancelled
AI automation / Reconcile handoffs (push) Has been cancelled
AI automation / Classify issue (push) Has been cancelled
AI automation / Claude Code smoke (push) Has been cancelled
AI automation / Review issue follow-up (push) Has been cancelled
AI automation / Publish issue follow-up (push) Has been cancelled
AI automation / Implement with Claude Code (push) Has been cancelled
AI automation / Publish implement PR (push) Has been cancelled
AI automation / Continue queued issue comments (push) Has been cancelled
AI automation / Codex review loop (push) Has been cancelled
AI automation / Publish Codex fix (push) Has been cancelled
AI automation / Clear Codex dispatch marker (push) Has been cancelled
AI automation / Own PR re-request Codex (push) Has been cancelled
AI automation / External PR re-request Codex (push) Has been cancelled
AI automation / Poll Codex reaction / retry (push) Has been cancelled
build-et-binaries / build-linux-x64 (push) Has been cancelled
build-et-binaries / build-linux-arm64 (push) Has been cancelled
build-et-binaries / build-macos-universal (push) Has been cancelled
build-et-binaries / build-windows-x64 (push) Has been cancelled
build-et-binaries / release (push) Has been cancelled
Some checks failed
build-packages / resolve bundled mosh-client (push) Has been cancelled
build-packages / resolve bundled et-client (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
build-packages / update Nix release metadata (push) Has been cancelled
build-packages / bump homebrew tap (push) Has been cancelled
test / lint-and-test (push) Has been cancelled
AI automation / Route event (push) Has been cancelled
AI automation / Hand reopened issue to maintainers (push) Has been cancelled
AI automation / Clean source issue state (push) Has been cancelled
AI automation / Reconcile handoffs (push) Has been cancelled
AI automation / Classify issue (push) Has been cancelled
AI automation / Claude Code smoke (push) Has been cancelled
AI automation / Review issue follow-up (push) Has been cancelled
AI automation / Publish issue follow-up (push) Has been cancelled
AI automation / Implement with Claude Code (push) Has been cancelled
AI automation / Publish implement PR (push) Has been cancelled
AI automation / Continue queued issue comments (push) Has been cancelled
AI automation / Codex review loop (push) Has been cancelled
AI automation / Publish Codex fix (push) Has been cancelled
AI automation / Clear Codex dispatch marker (push) Has been cancelled
AI automation / Own PR re-request Codex (push) Has been cancelled
AI automation / External PR re-request Codex (push) Has been cancelled
AI automation / Poll Codex reaction / retry (push) Has been cancelled
build-et-binaries / build-linux-x64 (push) Has been cancelled
build-et-binaries / build-linux-arm64 (push) Has been cancelled
build-et-binaries / build-macos-universal (push) Has been cancelled
build-et-binaries / build-windows-x64 (push) Has been cancelled
build-et-binaries / release (push) Has been cancelled
This commit is contained in:
472
electron/bridges/mcpServerBridge/execHandlers.cjs
Normal file
472
electron/bridges/mcpServerBridge/execHandlers.cjs
Normal 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 };
|
||||
Reference in New Issue
Block a user