Compare commits

..
Author SHA1 Message Date
bincxz 81dcf849fb fix(sftp): keep home candidates when realpath root is not listable
Accept realpath('/') as home only after a successful readdir so virtual/chroot
roots from #2934 still work, while non-listable root no longer skips
/home/<user> and /root probing. Also retry those candidates if listing fails.
2026-08-12 18:18:14 +08:00
陈大猫 1dfe1379cd Merge pull request #2934 from pplulee/codex/fix-sftp-virtual-root
修复 SFTP 虚拟根目录会话的传输失败
2026-08-12 18:10:02 +08:00
陈大猫 7ac878d00e Merge pull request #2939 from binaricat/fix/show-tab-number-badges-exhaustive-deps
fix: include setShowTabNumberBadgesState in storage sync deps
2026-08-12 18:02:00 +08:00
bincxz f90f40e624 fix: include setShowTabNumberBadgesState in storage sync deps
The storage event listener uses setShowTabNumberBadgesState but
omitted it from the useEffect dependency array, which triggered
react-hooks/exhaustive-deps.
2026-08-12 18:01:07 +08:00
陈大猫 2c2d7e0b55 Merge pull request #2937 from binaricat/codex/fix-theme-header-button-alignment
Fix theme header button alignment
2026-08-12 17:54:23 +08:00
陈大猫 700d603e64 Merge pull request #2925 from binaricat/cursor/issue-2922-31559806317
fix(ai): keep host_open sessions after sidebar scope sync
2026-08-12 17:40:46 +08:00
netcatty-bot f2b3facd9f fix: address Codex review on PR #2925 2026-08-12 09:32:41 +00:00
bincxz 7f08bec0b9 Merge remote-tracking branch 'origin/main' into codex/pr-2925-review
# Conflicts:
#	electron/bridges/terminalBridge.sessionActivity.test.cjs
2026-08-12 16:50:25 +08:00
bincxz bbd4c698a5 fix(ai): harden host_open session lifecycle 2026-08-12 16:42:43 +08:00
pplulee 60e057318f fix: handle SFTP virtual root sessions 2026-08-12 16:29:35 +08:00
netcatty-bot 9671468412 fix: address Codex review on PR #2925 2026-08-12 06:36:20 +00:00
netcatty-bot e4f9303de5 fix: address Codex review on PR #2925 2026-08-12 06:04:52 +00:00
netcatty-bot c8a393f12b fix: address Codex review on PR #2925 2026-08-12 05:25:45 +00:00
netcatty-bot 7114288d2f fix: address Codex review on PR #2925 2026-08-12 04:46:21 +00:00
netcatty-bot 0382c089e1 fix: address Codex review on PR #2925 2026-08-12 04:05:21 +00:00
netcatty-bot f8c7dd44db fix(#2922): automated Cursor CLI fix 2026-08-12 03:43:57 +00:00
31 changed files with 2145 additions and 104 deletions

No files matched your search

+1
View File
@@ -568,6 +568,7 @@ export function useSettingsStorageSync({
setShowRecentHostsState,
setShowSftpTabState,
setShellOnlyTabNumberShortcutsState,
setShowTabNumberBadgesState,
setDisableTerminalFontZoomState,
setRestorePreviousSessionState,
setRestoreTerminalCwdState,
@@ -2,7 +2,7 @@ import assert from "node:assert/strict";
import test from "node:test";
import type { Host } from "../../../domain/models";
import { ensureRemoteSftpSession } from "./ensureRemoteSftpSession";
import { ensureRemoteSftpSession, probeSftpSession } from "./ensureRemoteSftpSession";
import type { SftpPane } from "./types";
const host = {
@@ -37,6 +37,24 @@ const remotePane = (connectionId: string): SftpPane => ({
const noopReleaseConnection = async () => {};
test("SFTP session probe accepts a virtual root directory", async () => {
const calls: Array<[string, string]> = [];
const ok = await probeSftpSession({
realpathSftp: async (sftpId, remotePath) => {
calls.push([sftpId, remotePath]);
return "/";
},
}, "sftp-jumpserver");
assert.equal(ok, true);
assert.deepEqual(calls, [["sftp-jumpserver", "."]]);
});
test("SFTP session probe does not require home discovery", async () => {
const ok = await probeSftpSession(undefined, "sftp-live");
assert.equal(ok, true);
});
test("returns an existing mapped SFTP session without reconnecting", async () => {
let connectCalls = 0;
const sftpId = await ensureRemoteSftpSession({
@@ -3,6 +3,24 @@ import type { MutableRefObject } from "react";
import { isSessionError } from "./errors";
import type { SftpPane } from "./types";
export interface SftpSessionProbeBridge {
realpathSftp?: (sftpId: string, path: string) => Promise<string>;
}
/**
* Probe the SFTP protocol itself instead of using remote home discovery.
* Restricted/chroot servers can deny SSH exec or expose '/' as their virtual
* root while the SFTP session remains fully usable.
*/
export async function probeSftpSession(
bridge: SftpSessionProbeBridge | null | undefined,
sftpId: string,
): Promise<boolean> {
if (!bridge?.realpathSftp) return true;
await bridge.realpathSftp(sftpId, ".");
return true;
}
export interface EnsureRemoteSftpSessionParams {
side: "left" | "right";
getActivePane: (side: "left" | "right") => SftpPane | null;
@@ -5,6 +5,7 @@ import test from "node:test";
import assert from "node:assert/strict";
import {
buildSftpHomeDirCandidates,
createSftpConnectionId,
createPinnedReconnectSideResolver,
openSftpConnectionOnce,
@@ -15,6 +16,13 @@ import {
resolvePinnedReconnectSide,
} from "./useSftpConnections.ts";
test("home dir candidates prefer user home then root", () => {
assert.deepEqual(buildSftpHomeDirCandidates("deploy"), ["/home/deploy", "/root"]);
assert.deepEqual(buildSftpHomeDirCandidates("root"), ["/root"]);
assert.deepEqual(buildSftpHomeDirCandidates(undefined), ["/root"]);
assert.deepEqual(buildSftpHomeDirCandidates(null), ["/root"]);
});
test("connection ids stay unique even when connects start in the same millisecond", () => {
const ids = ["uuid-a", "uuid-b"];
assert.equal(createSftpConnectionId("left", () => ids.shift()!), "left-uuid-a");
+25 -9
View File
@@ -97,6 +97,13 @@ export async function releaseSftpConnectionMetadata(params: {
params.onRemoteSessionClosed?.(sftpId);
}
/** Hardcoded home-path candidates when SSH exec / listable realpath fail. */
export function buildSftpHomeDirCandidates(username?: string | null): string[] {
if (username === "root") return ["/root"];
if (username) return [`/home/${username}`, "/root"];
return ["/root"];
}
export function createSftpConnectionId(
side: "left" | "right",
randomUUID: () => string = () => crypto.randomUUID(),
@@ -698,15 +705,7 @@ export const useSftpConnections = ({
}
if (!detected) {
const candidates: string[] = [];
if (credentials.username === "root") {
candidates.push("/root");
} else if (credentials.username) {
candidates.push(`/home/${credentials.username}`);
candidates.push("/root");
} else {
candidates.push("/root");
}
const candidates = buildSftpHomeDirCandidates(credentials.username);
const statSftp = bridge?.statSftp;
if (statSftp) {
for (const candidate of candidates) {
@@ -784,6 +783,23 @@ export const useSftpConnections = ({
// root also failed
}
}
// Last resort: home candidates. Covers provisional "/" from realpath
// when discovery treated root as home and listing it failed, so
// /home/<user> or /root can still recover the session.
if (!fallbackSucceeded) {
for (const candidate of buildSftpHomeDirCandidates(credentials.username)) {
if (candidate === startPath) continue;
try {
files = await listRemoteFiles(sftpId, candidate, filenameEncoding);
startPath = candidate;
homeDir = candidate;
fallbackSucceeded = true;
break;
} catch {
// Ignore missing/permission errors
}
}
}
if (!fallbackSucceeded) {
throw new Error("Cannot list any remote directory");
}
@@ -0,0 +1,75 @@
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import test from 'node:test';
import { createLatestPayloadSync } from './useExternalMcpSessionSync.ts';
const hookSource = readFileSync(new URL('./useExternalMcpSessionSync.ts', import.meta.url), 'utf8');
const appSource = readFileSync(new URL('../app/AppSideEffects.tsx', import.meta.url), 'utf8');
const preloadSource = readFileSync(new URL('../../electron/preload/api.cjs', import.meta.url), 'utf8');
const handlerSource = readFileSync(
new URL('../../electron/bridges/aiBridge/agentProcessHandlers.cjs', import.meta.url),
'utf8',
);
test('app live session sync stays mounted and is independent of External MCP enablement', () => {
assert.match(appSource, /useExternalMcpSessionSync\(\{/);
assert.match(hookSource, /bridge\.aiMcpUpdateLiveSessions\?\.\(nextPayload\)/);
assert.match(hookSource, /liveSyncRef\.current\.push\(payload, serialized\)/);
assert.match(hookSource, /bridge\.aiMcpUpdateSessions\?\.\(/);
assert.match(hookSource, /externalSyncRef\.current\.push\(payload, serialized\)/);
assert.doesNotMatch(
hookSource,
/if \(!enabled\)[\s\S]{0,300}aiMcpUpdateLiveSessions/,
);
});
test('live session sync has a complete renderer to main-process IPC path', () => {
assert.match(preloadSource, /netcatty:ai:mcp:update-live-sessions/);
assert.match(handlerSource, /netcatty:ai:mcp:update-live-sessions/);
assert.match(handlerSource, /mcpServerBridge\.updateLiveSessionMetadata/);
});
test('latest payload sync converges to A-B-A while B is in flight', async () => {
let releaseB: (() => void) | undefined;
const sent: string[] = [];
const sync = createLatestPayloadSync(async (payload: string) => {
sent.push(payload);
if (payload === 'B') {
await new Promise<void>((resolve) => {
releaseB = resolve;
});
}
return { ok: true };
});
await sync.push('A');
const sendingB = sync.push('B');
void sync.push('A');
releaseB?.();
await sendingB;
assert.deepEqual(sent, ['A', 'B', 'A']);
});
test('cancelling a payload sync drops a queued update', async () => {
let releaseA: (() => void) | undefined;
const sent: string[] = [];
const sync = createLatestPayloadSync(async (payload: string) => {
sent.push(payload);
if (payload === 'A') {
await new Promise<void>((resolve) => {
releaseA = resolve;
});
}
return { ok: true };
});
const sendingA = sync.push('A');
void sync.push('B');
sync.cancel();
releaseA?.();
await sendingA;
assert.deepEqual(sent, ['A']);
});
+67 -18
View File
@@ -17,6 +17,49 @@ type UseExternalMcpSessionSyncOptions = {
portForwardingRules: PortForwardingRule[];
};
export function createLatestPayloadSync<T>(
send: (payload: T) => Promise<unknown>,
) {
let desired: { serialized: string; payload: T } | null = null;
let acknowledged = '';
let inFlight: Promise<void> | null = null;
let cancelled = false;
const pump = async () => {
while (!cancelled && desired && desired.serialized !== acknowledged) {
const target = desired;
try {
const result = await send(target.payload);
if (cancelled) break;
if (result && typeof result === 'object' && 'ok' in result && (result as { ok?: boolean }).ok === false) {
if (desired === target) break;
continue;
}
acknowledged = target.serialized;
} catch {
if (desired === target) break;
}
}
};
return {
push(payload: T, serialized = JSON.stringify(payload)): Promise<void> {
if (cancelled) return Promise.resolve();
desired = { payload, serialized };
if (!inFlight) {
inFlight = pump().finally(() => {
inFlight = null;
});
}
return inFlight;
},
cancel(): void {
cancelled = true;
desired = null;
},
};
}
function isMainAppWindow(): boolean {
if (typeof window === 'undefined') return false;
const hash = window.location.hash || '';
@@ -43,7 +86,6 @@ export function useExternalMcpSessionSync({
void enabledTick;
return readExternalMcpStoredEnabled();
}, [enabledTick]);
const syncGenerationRef = useRef(0);
useEffect(() => {
const bump = () => setEnabledTick((value) => value + 1);
@@ -77,34 +119,41 @@ export function useExternalMcpSessionSync({
});
}, [sessions, sessionHostsMap, hosts, portForwardingRules]);
const lastSentSerializedRef = useRef('');
const liveSyncRef = useRef<ReturnType<typeof createLatestPayloadSync<typeof payload>> | null>(null);
const externalSyncRef = useRef<ReturnType<typeof createLatestPayloadSync<typeof payload>> | null>(null);
useEffect(() => {
if (!isMainAppWindow()) return;
const bridge = netcattyBridge.get();
if (!bridge?.aiMcpUpdateLiveSessions) return;
const serialized = JSON.stringify(payload);
liveSyncRef.current ??= createLatestPayloadSync((nextPayload) => (
Promise.resolve(bridge.aiMcpUpdateLiveSessions?.(nextPayload))
));
void liveSyncRef.current.push(payload, serialized);
}, [payload]);
useEffect(() => {
if (!isMainAppWindow()) return;
if (!enabled) {
syncGenerationRef.current += 1;
lastSentSerializedRef.current = '';
// Recreate the synchronizer after re-enabling because disabling can
// clear the main-process scope even when the renderer payload is equal.
externalSyncRef.current?.cancel();
externalSyncRef.current = null;
return;
}
const bridge = netcattyBridge.get();
if (!bridge?.aiMcpUpdateSessions) return;
const serialized = JSON.stringify(payload);
if (serialized === lastSentSerializedRef.current) return;
const generation = ++syncGenerationRef.current;
const timeoutId = window.setTimeout(() => {
void Promise.resolve(bridge.aiMcpUpdateSessions?.(payload, EXTERNAL_MCP_CHAT_SESSION_ID))
.then((result) => {
if (generation !== syncGenerationRef.current) return;
if (result && typeof result === 'object' && 'ok' in result && (result as { ok?: boolean }).ok === false) {
return;
}
lastSentSerializedRef.current = serialized;
})
.catch(() => {
// Leave the ref unset so the next effect can retry.
});
externalSyncRef.current ??= createLatestPayloadSync((nextPayload) => (
Promise.resolve(bridge.aiMcpUpdateSessions?.(
nextPayload,
EXTERNAL_MCP_CHAT_SESSION_ID,
))
));
void externalSyncRef.current.push(payload, serialized);
}, 250);
return () => {
+3 -20
View File
@@ -30,7 +30,7 @@ import { buildSftpHostCredentials } from "./sftp/useSftpHostCredentials";
import { useSftpFileWatch } from "./sftp/useSftpFileWatch";
import { useSftpSessionCleanup } from "./sftp/useSftpSessionCleanup";
import { useSftpSessionErrors } from "./sftp/useSftpSessionErrors";
import { ensureRemoteSftpSession } from "./sftp/ensureRemoteSftpSession";
import { ensureRemoteSftpSession, probeSftpSession } from "./sftp/ensureRemoteSftpSession";
import { openTransferSftpSession } from "./sftp/dedicatedTransferResume";
import {
createTransferPoolKeyCache,
@@ -608,16 +608,7 @@ export const useSftpState = (
forceReconnect: ensureOptions?.forceReconnect,
releaseConnection,
tabId,
probeSession: async (sftpId) => {
// Lightweight liveness check; any session-error from the bridge
// triggers a reconnect in ensureRemoteSftpSession.
if (!bridge?.getSftpHomeDir) return true;
const result = await bridge.getSftpHomeDir(sftpId);
if (result && result.success === false) {
throw new Error(result.error || "SFTP session not found");
}
return true;
},
probeSession: (sftpId) => probeSftpSession(bridge, sftpId),
});
},
resolveConnectedHost: (tabId) => connectedHostByTabIdRef.current.get(tabId) ?? null,
@@ -714,15 +705,7 @@ export const useSftpState = (
resolveConnectedHost: (id) => connectedHostByTabIdRef.current.get(id) ?? null,
resolveHostById: (hostId) => hosts.find((host) => host.id === hostId) ?? null,
resolveSourceSessionId: resolveBrowseSourceSessionId,
probeSession: async (sftpId) => {
const bridge = netcattyBridge.get();
if (!bridge?.getSftpHomeDir) return true;
const result = await bridge.getSftpHomeDir(sftpId);
if (result && result.success === false) {
throw new Error(result.error || "SFTP session not found");
}
return true;
},
probeSession: (sftpId) => probeSftpSession(netcattyBridge.get(), sftpId),
releaseConnection,
});
logger.info(`[SFTP] Restored browse session on ${side}`);
+11 -5
View File
@@ -1617,7 +1617,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
terminalCwdTracker,
]);
const cleanupSession = async () => {
const cleanupSession = async (options?: { retainOwnership?: boolean }) => {
const closingSessionId = sessionRef.current;
xtermRuntimeRef.current?.flushKittyKeyboardReleases();
sessionRef.current = null;
@@ -1645,7 +1645,10 @@ const TerminalComponent: React.FC<TerminalProps> = ({
// Still notify main so in-flight SSH passphrase prompts for this UI
// sessionId are aborted even before a backend session was attached.
try {
await terminalBackend.closeSession(sessionId, { bootEpoch: resolveCloseBootEpoch() });
await terminalBackend.closeSession(sessionId, {
bootEpoch: resolveCloseBootEpoch(),
...(options?.retainOwnership === true ? { retainOwnership: true } : {}),
});
} catch (err) {
logger.warn("Failed to cancel pending session boot on disconnect", err);
}
@@ -1754,7 +1757,10 @@ const TerminalComponent: React.FC<TerminalProps> = ({
clearTerminalSessionFlowAck(closingSessionId);
}
try {
await terminalBackend.closeSession(closingSessionId, { bootEpoch: resolveCloseBootEpoch() });
await terminalBackend.closeSession(closingSessionId, {
bootEpoch: resolveCloseBootEpoch(),
...(options?.retainOwnership === true ? { retainOwnership: true } : {}),
});
} catch (err) {
logger.warn("Failed to close SSH session", err);
}
@@ -3303,7 +3309,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
setPendingHostKeyRequestId(null);
setError(null);
setProgressLogs((prev) => [...prev, "Disconnected by user."]);
void cleanupSession();
void cleanupSession({ retainOwnership: true });
updateStatus("disconnected");
setChainProgress(null);
setIsDisconnectedDialogDismissed(false);
@@ -3472,7 +3478,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
}
try {
await cleanupSession();
await cleanupSession({ retainOwnership: true });
} catch (error) {
finishReconnectPreparation();
throw error;
+1 -1
View File
@@ -394,7 +394,7 @@ test("manual disconnect keeps the session pane for reconnect", () => {
assert.match(body, /isBootActiveRef\.current = false/);
assert.match(body, /setIsCancelling\(true\)/);
assert.match(body, /updateStatus\("disconnected"\)/);
assert.match(body, /void cleanupSession\(\)/);
assert.match(body, /void cleanupSession\(\{ retainOwnership: true \}\)/);
assert.match(source, /trackSessionCleanup/);
assert.doesNotMatch(body, /onCloseSession/);
assert.match(source, /handleDisconnect: \(attachExistingSession \|\| compactToolbar\) \? undefined : handleDisconnect/);
@@ -164,7 +164,7 @@ test("reconnect preparation cancels an in-flight disconnected hibernate", () =>
const claimIndex = reconnectBody.indexOf(
"reconnectPreparationTokenRef.current = retryToken",
);
const cleanupIndex = reconnectBody.indexOf("await cleanupSession()");
const cleanupIndex = reconnectBody.indexOf("await cleanupSession({ retainOwnership: true })");
const startNewSessionIndex = reconnectBody.indexOf("const startNewSession = () =>");
assert.ok(claimIndex >= 0 && claimIndex < cleanupIndex);
@@ -27,6 +27,16 @@ function registerAgentProcessHandlers(ctx) {
return { ok: true, count: list.length };
});
// App-owned live session state is independent of the optional External MCP
// surface. It lets host_open-owned chat scopes observe connection changes
// even when the opened terminal never mounts its own AI side panel.
ipcMain.handle("netcatty:ai:mcp:update-live-sessions", async (event, { sessions: sessionList }) => {
if (!validateSenderOrSettings(event)) return { ok: false, error: "Unauthorized IPC sender" };
return mcpServerBridge.updateLiveSessionMetadata(
Array.isArray(sessionList) ? sessionList : [],
);
});
// Merge (do not replace) session metadata into a chat scope. Used when agents
// open a host mid-turn so terminal tools can target the new sessionId
// without waiting for the next full scope push.
+223 -21
View File
@@ -94,8 +94,14 @@ function disconnectExternalMcpClients() {
// Per-scope metadata: chatSessionId → { sessionIds: string[], metadata: Map<sessionId, meta> }
// Each chat session only sees the hosts registered for its scope.
const scopedMetadata = new Map();
let liveSessionMetadata = new Map();
let sessionMetadataRevision = 0;
let terminalSessionCloseRevision = 0;
const closedTerminalSessionRevisions = new Map();
const MAX_CLOSED_TERMINAL_SESSION_REVISIONS = 2048;
const scopedAttachments = new Map(); // chatSessionId -> Map<filePath, attachment>
const { createSessionOwnershipRegistry } = require("./mcpServerBridge/sessionOwnership.cjs");
const { retainOwnedSessions, mergeRetentionMeta } = require("./mcpServerBridge/retainOwnedSessions.cjs");
const {
createSessionIdleManager,
normalizeSessionIdleTimeoutMinutes,
@@ -368,6 +374,8 @@ const {
releaseSessionExecution,
} = backgroundJobApi;
let disposeWorkerSessionClosed = null;
function init(deps) {
sessions = deps.sessions;
terminalWorkerManager = deps.terminalWorkerManager || null;
@@ -378,6 +386,23 @@ function init(deps) {
if (deps.commandBlocklist) {
commandBlocklist = deps.commandBlocklist;
}
try { disposeWorkerSessionClosed?.dispose?.(); } catch { /* ignore */ }
disposeWorkerSessionClosed = null;
// Only explicit UI/tab closes drop host_open ownership. Shell "exited"
// events (including missing/nonzero exitCode) and recoverable exits
// (error / worker-exit / superseded / quiet transport "closed") keep
// ownership so a disconnected tab can reconnect with the same session id.
// Auto-close clean exits still forget ownership when the renderer closes
// the tab and the worker emits explicit:true.
if (typeof terminalWorkerManager?.onSessionClosed === "function") {
disposeWorkerSessionClosed = terminalWorkerManager.onSessionClosed((event) => {
const sessionId = event?.sessionId;
if (!sessionId) return;
if (event?.explicit === true) {
forgetClosedTerminalSession(sessionId);
}
});
}
}
async function listActivePortForwards() {
@@ -441,6 +466,7 @@ function shutdownHost({ preserveScopedMetadata = false } = {}) {
if (!preserveScopedMetadata) {
scopedMetadata.clear();
scopedAttachments.clear();
liveSessionMetadata.clear();
}
for (const [, job] of backgroundJobs) {
try {
@@ -598,12 +624,50 @@ function syncLiveSessionsToExternalScope(chatSessionId = EXTERNAL_MCP_CHAT_SESSI
return { ok: true, count: sessionList.length, chatSessionId };
}
function findSessionMetaAcrossScopes(sessionId) {
for (const scoped of scopedMetadata.values()) {
function findSessionMetaAcrossScopes(sessionId, excludeChatSessionId = null) {
let latest = liveSessionMetadata.get(sessionId) || null;
let latestRevision = Number.isSafeInteger(latest?._revision) ? latest._revision : -1;
for (const [scopeId, scoped] of scopedMetadata.entries()) {
if (excludeChatSessionId && scopeId === excludeChatSessionId) continue;
const meta = scoped?.metadata?.get?.(sessionId);
if (meta) return meta;
if (!meta) continue;
const revision = Number.isSafeInteger(meta._revision) ? meta._revision : 0;
if (revision > latestRevision) {
latest = meta;
latestRevision = revision;
}
}
return null;
return latest;
}
/**
* App-owned live session snapshot. Unlike the External MCP scope, this is
* always refreshed by the main renderer and is never exposed as a scope of
* its own. It only supplies current state for session ids a chat already owns.
*/
function updateLiveSessionMetadata(sessionList) {
const incoming = Array.isArray(sessionList) ? sessionList : [];
const updateRevision = ++sessionMetadataRevision;
const next = new Map();
for (const entry of incoming) {
if (!entry || typeof entry !== "object" || !entry.sessionId) continue;
next.set(entry.sessionId, {
hostname: entry.hostname || "",
label: entry.label || "",
os: entry.os || "",
username: entry.username || "",
protocol: entry.protocol || "",
shellType: entry.shellType || "",
deviceType: entry.deviceType || "",
connected: entry.connected !== false,
hostId: entry.hostId || "",
hostChain: Array.isArray(entry.hostChain) ? entry.hostChain : [],
activePortForwards: Array.isArray(entry.activePortForwards) ? entry.activePortForwards : [],
_revision: updateRevision,
});
}
liveSessionMetadata = next;
return { ok: true, count: next.size };
}
function seedExternalScopeFromOtherScopes(chatSessionId) {
@@ -612,7 +676,10 @@ function seedExternalScopeFromOtherScopes(chatSessionId) {
if (scopeId === chatSessionId || !scoped?.metadata) continue;
for (const [sessionId, meta] of scoped.metadata.entries()) {
if (!sessionId || !meta) continue;
if (!byId.has(sessionId)) {
const existing = byId.get(sessionId);
const existingRevision = Number.isSafeInteger(existing?._revision) ? existing._revision : 0;
const revision = Number.isSafeInteger(meta._revision) ? meta._revision : 0;
if (!existing || revision > existingRevision) {
byId.set(sessionId, { sessionId, ...meta });
}
}
@@ -680,14 +747,34 @@ function beginChatExecution(chatSessionId, sessionId, command) {
* @param {string} [chatSessionId] - AI chat session ID for per-scope isolation
*/
function updateSessionMetadata(sessionList, chatSessionId) {
const incoming = Array.isArray(sessionList) ? sessionList : [];
const updateRevision = ++sessionMetadataRevision;
// Authoritative empty replace: clear retention ownership for this scope so a
// later non-empty sync cannot resurrect sessions via cross-scope fallback.
if (chatSessionId && incoming.length === 0) {
openedSessionOwnership.releaseScopeOwnership(chatSessionId);
}
// host_open merges the new session into this chat scope, but the AI side
// panel later pushes a full replace of only the currently focused tab —
// which would drop mid-turn opened sessions. Retain ownership-tracked ids.
const effectiveList = chatSessionId
? retainOwnedSessions({
incomingSessions: incoming,
ownedSessionIds: openedSessionOwnership.listOwned(chatSessionId),
previousById: scopedMetadata.get(chatSessionId)?.metadata || null,
// Skip the current scope so a stale connected:false snapshot cannot
// shadow a fresher copy from External MCP / another chat tab.
findFallbackMeta: (sessionId) => findSessionMetaAcrossScopes(sessionId, chatSessionId),
})
: incoming;
debugLog("updateSessionMetadata", {
chatSessionId,
count: Array.isArray(sessionList) ? sessionList.length : 0,
sessionIds: Array.isArray(sessionList) ? sessionList.map(s => s.sessionId) : [],
count: effectiveList.length,
sessionIds: effectiveList.map(s => s.sessionId),
});
const ids = sessionList.map(s => s.sessionId);
const ids = effectiveList.map(s => s.sessionId);
const metaMap = new Map();
for (const s of sessionList) {
for (const s of effectiveList) {
metaMap.set(s.sessionId, {
hostname: s.hostname || "",
label: s.label || "",
@@ -700,6 +787,7 @@ function updateSessionMetadata(sessionList, chatSessionId) {
hostId: s.hostId || "",
hostChain: Array.isArray(s.hostChain) ? s.hostChain : [],
activePortForwards: Array.isArray(s.activePortForwards) ? s.activePortForwards : [],
_revision: Number.isSafeInteger(s._revision) ? s._revision : updateRevision,
});
}
@@ -919,11 +1007,92 @@ function resolveScopedSessionIds(chatSessionId, explicitScopedIds = null) {
/**
* Look up metadata for a sessionId, scoped to a specific chat session.
* Falls back to session object properties if no scoped metadata is found.
* When this scope still has a stale connected:false snapshot (common after
* host_open + tab switch), refresh connected from a fresher cross-scope copy.
* Owned host_open sessions also refresh while still connected so another
* scope's empty activePortForwards reaches getContext without a later sidebar
* replace.
*/
function getSessionMeta(sessionId, chatSessionId) {
if (!chatSessionId) return null;
const scoped = scopedMetadata.get(chatSessionId);
return scoped?.metadata?.get(sessionId) || null;
const meta = scoped?.metadata?.get(sessionId) || null;
if (!meta) return null;
const owned = openedSessionOwnership.validate(chatSessionId, sessionId).ok;
// Live non-owned snapshots stay local. Owned sessions (and stale
// connected:false copies) consult other scopes.
if (!owned && meta.connected !== false) return toPublicSessionMeta(meta);
const fresher = findSessionMetaAcrossScopes(sessionId, chatSessionId);
if (!fresher) return toPublicSessionMeta(meta);
if (!owned && fresher.connected === false) return toPublicSessionMeta(meta);
const refreshed = mergeRetentionMeta(meta, fresher);
if (!refreshed) return toPublicSessionMeta(meta);
scoped.metadata.set(sessionId, refreshed);
return toPublicSessionMeta(refreshed);
}
function toPublicSessionMeta(meta) {
if (!meta || typeof meta !== "object") return meta;
const { _revision, ...publicMeta } = meta;
return publicMeta;
}
function buildOpenedSessionMeta(result, sessionId) {
const host = result?.host && typeof result.host === "object" ? result.host : {};
return {
sessionId,
hostname: host.hostname || "",
label: host.label || host.hostname || sessionId,
os: host.os || "",
username: host.username || "",
protocol: result?.protocol || host.protocol || "",
shellType: "",
deviceType: host.deviceType || "",
connected: result?.status === "connected",
hostId: result?.hostId || host.id || "",
hostChain: [],
activePortForwards: [],
};
}
function ensureOpenedSessionMetadata(chatSessionId, sessionId, result) {
if (!chatSessionId || !sessionId) return;
if (scopedMetadata.get(chatSessionId)?.metadata?.has(sessionId)) return;
const latest = findSessionMetaAcrossScopes(sessionId, chatSessionId);
mergeSessionMetadata([
latest ? { sessionId, ...latest } : buildOpenedSessionMeta(result, sessionId),
], chatSessionId);
}
/**
* Drop host_open ownership and scoped metadata after a terminal session is
* actually closed (explicit UI tab close or agent session_close).
*/
function forgetClosedTerminalSession(sessionId) {
if (!sessionId) return;
terminalSessionCloseRevision += 1;
closedTerminalSessionRevisions.delete(sessionId);
closedTerminalSessionRevisions.set(sessionId, terminalSessionCloseRevision);
while (closedTerminalSessionRevisions.size > MAX_CLOSED_TERMINAL_SESSION_REVISIONS) {
closedTerminalSessionRevisions.delete(closedTerminalSessionRevisions.keys().next().value);
}
sessionIdleManager.forgetSession(sessionId);
openedSessionOwnership.forgetSession(sessionId);
liveSessionMetadata.delete(sessionId);
for (const scoped of scopedMetadata.values()) {
scoped.sessionIds = scoped.sessionIds.filter((id) => id !== sessionId);
scoped.metadata.delete(sessionId);
}
}
function forgetUnownedTerminalSessionMetadata(sessionId) {
if (!sessionId) return;
openedSessionOwnership.forgetSession(sessionId);
liveSessionMetadata.delete(sessionId);
for (const scoped of scopedMetadata.values()) {
scoped.sessionIds = scoped.sessionIds.filter((id) => id !== sessionId);
scoped.metadata.delete(sessionId);
}
}
/**
@@ -1192,6 +1361,10 @@ const sessionIdleManager = createSessionIdleManager({
function reportOpenedSessionActivity(event = {}) {
const sessionId = event?.sessionId;
if (!sessionId) return false;
if (event.phase === "closed") {
forgetClosedTerminalSession(sessionId);
return true;
}
if (event.phase === "begin") {
return sessionIdleManager.beginActivity(null, sessionId);
}
@@ -1231,20 +1404,14 @@ sessionService = createSessionService({
endTerminalSessionClose(params.sessionId);
if (outcome.closed) return;
if (outcome.notFound) {
sessionIdleManager.forgetSession(params.sessionId);
openedSessionOwnership.forgetSession(params.sessionId);
forgetClosedTerminalSession(params.sessionId);
return;
}
sessionIdleManager.resume(params.sessionId);
},
onClosed: async (sessionId) => {
await settleBackgroundJobsForTerminalSession(sessionId);
sessionIdleManager.forgetSession(sessionId);
openedSessionOwnership.forgetSession(sessionId);
for (const scoped of scopedMetadata.values()) {
scoped.sessionIds = scoped.sessionIds.filter((id) => id !== sessionId);
scoped.metadata.delete(sessionId);
}
forgetClosedTerminalSession(sessionId);
},
});
@@ -1267,9 +1434,40 @@ const dispatchCapabilityRpc = createCapabilityRpcDispatcher({
USER_DENIED_MESSAGE,
listPortForwards: () => listActivePortForwards(),
sessionService,
captureHostOpenScope: (chatSessionId) => openedSessionOwnership.captureGeneration(chatSessionId),
onHostOpened: (chatSessionId, sessionId, generation) => {
openedSessionOwnership.register(chatSessionId, sessionId, generation);
captureHostOpenScope: (chatSessionId) => ({
ownershipGeneration: openedSessionOwnership.captureGeneration(chatSessionId),
closeRevision: terminalSessionCloseRevision,
}),
onHostOpened: async (chatSessionId, sessionId, generation, result) => {
// The renderer creates the tab before replying to host_open. If the user
// explicitly closes it while that reply is still in flight, the eventual
// result must not revive ownership or scope metadata for the closed tab.
if ((closedTerminalSessionRevisions.get(sessionId) || 0) > generation?.closeRevision) return;
if (!openedSessionOwnership.register(
chatSessionId,
sessionId,
generation?.ownershipGeneration ?? generation,
)) {
// The chat may have been deleted while the renderer was creating the
// terminal. The generation revoke correctly rejects ownership, but the
// newly created backend session must also be reclaimed or it becomes an
// unowned session that no agent can close.
// host_open mints a fresh tab/session id. A reconnect with that id is the
// same logical tab and must also be reclaimed after its initiating chat
// was deleted; unrelated sessions do not reuse the minted id.
sessionIdleManager.track(chatSessionId, sessionId);
await sessionService.closeTracked({ chatSessionId, sessionId });
// A failed close stays idle-tracked so the normal bounded retry can try
// again. Remove only scope/live metadata here so a deleted chat cannot be
// recreated by the renderer's late merge.
forgetUnownedTerminalSessionMetadata(sessionId);
return;
}
closedTerminalSessionRevisions.delete(sessionId);
// A full empty scope replace can land after the renderer's best-effort
// host_open merge but before this async operation completes. Once the open
// succeeds, restore the returned session atomically with its ownership.
ensureOpenedSessionMetadata(chatSessionId, sessionId, result);
sessionIdleManager.track(chatSessionId, sessionId);
},
});
@@ -1992,6 +2190,8 @@ const configAndCleanupApi = createConfigAndCleanupApi({
const { resolveMcpServerRuntimeCommand, buildMcpServerConfig, cleanupScopedMetadata } = configAndCleanupApi;
function cleanup() {
try { disposeWorkerSessionClosed?.dispose?.(); } catch { /* ignore */ }
disposeWorkerSessionClosed = null;
shutdownHost();
}
@@ -2013,6 +2213,7 @@ module.exports = {
applyChatSessionCancelled,
checkCommandSafety,
updateSessionMetadata,
updateLiveSessionMetadata,
mergeSessionMetadata,
updateAttachmentMetadata,
handleListAttachments,
@@ -2029,6 +2230,7 @@ module.exports = {
hasActiveWorkerJobForTerminalSession,
cancelSftpOpsForSession,
getSessionMeta,
forgetClosedTerminalSession,
cleanupScopedMetadata,
cleanup,
shutdownHost,
@@ -0,0 +1,816 @@
"use strict";
const assert = require("node:assert/strict");
const test = require("node:test");
function loadFreshBridge() {
const bridgePath = require.resolve("./mcpServerBridge.cjs");
delete require.cache[bridgePath];
// Also clear retain/ownership deps so a fresh bridge gets fresh module state.
for (const key of Object.keys(require.cache)) {
if (key.includes(`${require("node:path").sep}mcpServerBridge${require("node:path").sep}`)) {
delete require.cache[key];
}
}
return require("./mcpServerBridge.cjs");
}
test("sidebar scope replace keeps host_open-owned sessions in the chat scope", async (t) => {
const bridge = loadFreshBridge();
t.after(() => bridge.cleanup());
bridge.init({
sessions: new Map(),
electronModule: null,
});
bridge.setPermissionMode("auto");
bridge.setVaultAgentInvoker(async (op) => {
if (op === "host.open") {
return { ok: true, sessionId: "sess-opened", hostId: "host-b", status: "connecting" };
}
return { ok: false, error: `unexpected op ${op}` };
});
bridge.updateSessionMetadata([
{
sessionId: "sess-original",
hostname: "10.0.0.1",
label: "server-a",
connected: true,
hostId: "host-a",
},
{
sessionId: "sess-opened",
hostname: "10.0.0.2",
label: "server-b",
connected: false,
hostId: "host-b",
protocol: "ssh",
},
], "chat-1");
const opened = await bridge.dispatchBuiltinRpc("public/vault/hosts/open", {
chatSessionId: "chat-1",
hostId: "host-b",
});
assert.equal(opened.ok, true);
assert.equal(opened.sessionId, "sess-opened");
// AIChatSidePanel-style full replace of only the focused tab.
bridge.updateSessionMetadata([
{
sessionId: "sess-original",
hostname: "10.0.0.1",
label: "server-a",
connected: true,
hostId: "host-a",
},
], "chat-1");
assert.deepEqual(
bridge.getScopedSessionIds("chat-1").sort(),
["sess-opened", "sess-original"],
);
});
test("authoritative empty scope replace still clears host_open-owned sessions", async (t) => {
const bridge = loadFreshBridge();
t.after(() => bridge.cleanup());
bridge.init({
sessions: new Map(),
electronModule: null,
});
bridge.setPermissionMode("auto");
bridge.setVaultAgentInvoker(async () => ({
ok: true,
sessionId: "sess-opened",
hostId: "host-b",
status: "connecting",
}));
bridge.updateSessionMetadata([
{ sessionId: "sess-opened", hostname: "10.0.0.2", label: "server-b", connected: false },
], "chat-1");
await bridge.dispatchBuiltinRpc("public/vault/hosts/open", {
chatSessionId: "chat-1",
hostId: "host-b",
});
bridge.updateSessionMetadata([], "chat-1");
assert.deepEqual(bridge.getScopedSessionIds("chat-1"), []);
// A later non-empty sync must not resurrect the cleared owned session via
// cross-scope fallback / stale ownership.
bridge.updateSessionMetadata([
{
sessionId: "sess-opened",
hostname: "10.0.0.2",
label: "server-b",
connected: true,
hostId: "host-b",
},
], "__external_mcp__");
bridge.updateSessionMetadata([
{
sessionId: "sess-original",
hostname: "10.0.0.1",
label: "server-a",
connected: true,
hostId: "host-a",
},
], "chat-1");
assert.deepEqual(bridge.getScopedSessionIds("chat-1"), ["sess-original"]);
assert.equal(bridge.getSessionMeta("sess-opened", "chat-1"), null);
});
test("retained host_open metadata refreshes connected from another scope", async (t) => {
const bridge = loadFreshBridge();
t.after(() => bridge.cleanup());
bridge.init({
sessions: new Map(),
electronModule: null,
});
bridge.setPermissionMode("auto");
bridge.setVaultAgentInvoker(async () => ({
ok: true,
sessionId: "sess-opened",
hostId: "host-b",
status: "connecting",
}));
bridge.updateSessionMetadata([
{
sessionId: "sess-opened",
hostname: "10.0.0.2",
label: "server-b",
connected: false,
hostId: "host-b",
protocol: "ssh",
},
], "chat-1");
await bridge.dispatchBuiltinRpc("public/vault/hosts/open", {
chatSessionId: "chat-1",
hostId: "host-b",
});
// Another surface (External MCP / opened tab) learns the session connected.
bridge.updateSessionMetadata([
{
sessionId: "sess-opened",
hostname: "10.0.0.2",
label: "server-b",
connected: true,
hostId: "host-b",
protocol: "ssh",
username: "root",
},
], "__external_mcp__");
// Original chat sidebar still pushes only the focused tab.
bridge.updateSessionMetadata([
{
sessionId: "sess-original",
hostname: "10.0.0.1",
label: "server-a",
connected: true,
hostId: "host-a",
},
], "chat-1");
assert.equal(bridge.getSessionMeta("sess-opened", "chat-1")?.connected, true);
assert.equal(bridge.getSessionMeta("sess-opened", "chat-1")?.username, "root");
});
test("retained host_open metadata accepts a later disconnect from another scope", async (t) => {
const bridge = loadFreshBridge();
t.after(() => bridge.cleanup());
bridge.init({
sessions: new Map(),
electronModule: null,
});
bridge.setPermissionMode("auto");
bridge.setVaultAgentInvoker(async () => ({
ok: true,
sessionId: "sess-opened",
hostId: "host-b",
status: "connecting",
}));
bridge.updateSessionMetadata([
{
sessionId: "sess-opened",
hostname: "10.0.0.2",
label: "server-b",
connected: true,
hostId: "host-b",
protocol: "ssh",
},
], "chat-1");
await bridge.dispatchBuiltinRpc("public/vault/hosts/open", {
chatSessionId: "chat-1",
hostId: "host-b",
});
// The focused tab moved away; External MCP later learns the host dropped.
bridge.updateSessionMetadata([
{
sessionId: "sess-opened",
hostname: "10.0.0.2",
label: "server-b",
connected: false,
hostId: "host-b",
protocol: "ssh",
},
], "__external_mcp__");
bridge.updateSessionMetadata([
{
sessionId: "sess-original",
hostname: "10.0.0.1",
label: "server-a",
connected: true,
hostId: "host-a",
},
], "chat-1");
assert.equal(bridge.getSessionMeta("sess-opened", "chat-1")?.connected, false);
});
test("getSessionMeta clears stopped port forwards from another scope while owned session stays connected", async (t) => {
const bridge = loadFreshBridge();
t.after(() => bridge.cleanup());
bridge.init({
sessions: new Map(),
electronModule: null,
});
bridge.setPermissionMode("auto");
bridge.setVaultAgentInvoker(async () => ({
ok: true,
sessionId: "sess-opened",
hostId: "host-b",
status: "connecting",
}));
const forwards = [{ ruleId: "fwd-1", localPort: 8080, status: "active" }];
bridge.updateSessionMetadata([
{
sessionId: "sess-opened",
hostname: "10.0.0.2",
label: "server-b",
connected: true,
hostId: "host-b",
protocol: "ssh",
activePortForwards: forwards,
},
], "chat-1");
await bridge.dispatchBuiltinRpc("public/vault/hosts/open", {
chatSessionId: "chat-1",
hostId: "host-b",
});
// Another surface reports the session still up, but forwards already stopped.
// chat-1 does not get another metadata replace (focused tab stayed elsewhere).
bridge.updateSessionMetadata([
{
sessionId: "sess-opened",
hostname: "10.0.0.2",
label: "server-b",
connected: true,
hostId: "host-b",
protocol: "ssh",
activePortForwards: [],
},
], "__external_mcp__");
assert.equal(bridge.getSessionMeta("sess-opened", "chat-1")?.connected, true);
assert.deepEqual(bridge.getSessionMeta("sess-opened", "chat-1")?.activePortForwards, []);
});
test("getSessionMeta does not let another scope clear forwards on a live non-owned session", async (t) => {
const bridge = loadFreshBridge();
t.after(() => bridge.cleanup());
bridge.init({
sessions: new Map(),
electronModule: null,
});
const forwards = [{ ruleId: "fwd-1", localPort: 8080, status: "active" }];
bridge.updateSessionMetadata([
{
sessionId: "sess-live",
hostname: "10.0.0.1",
label: "server-a",
connected: true,
hostId: "host-a",
activePortForwards: forwards,
},
], "chat-1");
bridge.updateSessionMetadata([
{
sessionId: "sess-live",
hostname: "10.0.0.1",
label: "server-a",
connected: true,
hostId: "host-a",
activePortForwards: [],
},
], "__external_mcp__");
assert.deepEqual(
bridge.getSessionMeta("sess-live", "chat-1")?.activePortForwards,
forwards,
);
});
test("ordinary tab close forgets host_open ownership so sidebar sync cannot revive ghosts", async (t) => {
const bridge = loadFreshBridge();
t.after(() => bridge.cleanup());
const closedListeners = new Set();
bridge.init({
sessions: new Map(),
electronModule: null,
terminalWorkerManager: {
onSessionClosed(listener) {
closedListeners.add(listener);
return {
dispose: () => closedListeners.delete(listener),
};
},
},
});
bridge.setPermissionMode("auto");
bridge.setVaultAgentInvoker(async () => ({
ok: true,
sessionId: "sess-opened",
hostId: "host-b",
status: "connecting",
}));
bridge.updateSessionMetadata([
{
sessionId: "sess-opened",
hostname: "10.0.0.2",
label: "server-b",
connected: true,
hostId: "host-b",
},
], "chat-1");
await bridge.dispatchBuiltinRpc("public/vault/hosts/open", {
chatSessionId: "chat-1",
hostId: "host-b",
});
// User closes the host_open tab through the normal UI / worker path.
for (const listener of closedListeners) {
listener({ sessionId: "sess-opened", reason: "closed", explicit: true });
}
bridge.updateSessionMetadata([
{
sessionId: "sess-original",
hostname: "10.0.0.1",
label: "server-a",
connected: true,
hostId: "host-a",
},
], "chat-1");
assert.deepEqual(bridge.getScopedSessionIds("chat-1"), ["sess-original"]);
assert.equal(bridge.getSessionMeta("sess-opened", "chat-1"), null);
});
test("recoverable worker exits keep host_open ownership for reconnect", async (t) => {
const bridge = loadFreshBridge();
t.after(() => bridge.cleanup());
const closedListeners = new Set();
bridge.init({
sessions: new Map(),
electronModule: null,
terminalWorkerManager: {
onSessionClosed(listener) {
closedListeners.add(listener);
return {
dispose: () => closedListeners.delete(listener),
};
},
},
});
bridge.setPermissionMode("auto");
bridge.setVaultAgentInvoker(async () => ({
ok: true,
sessionId: "sess-opened",
hostId: "host-b",
status: "connecting",
}));
bridge.updateSessionMetadata([
{
sessionId: "sess-opened",
hostname: "10.0.0.2",
label: "server-b",
connected: true,
hostId: "host-b",
},
], "chat-1");
await bridge.dispatchBuiltinRpc("public/vault/hosts/open", {
chatSessionId: "chat-1",
hostId: "host-b",
});
for (const event of [
{ reason: "error" },
{ reason: "worker-exit" },
{ reason: "superseded" },
{ reason: "closed" },
// Shell exits that leave the tab for reconnect (missing/nonzero exitCode).
{ reason: "exited" },
{ reason: "exited", exitCode: 1 },
]) {
for (const listener of closedListeners) {
listener({ sessionId: "sess-opened", ...event });
}
}
// After reconnect, sidebar may push only the focused tab; ownership must
// still retain the host_open session.
bridge.updateSessionMetadata([
{
sessionId: "sess-original",
hostname: "10.0.0.1",
label: "server-a",
connected: true,
hostId: "host-a",
},
], "chat-1");
assert.deepEqual(
bridge.getScopedSessionIds("chat-1").sort(),
["sess-opened", "sess-original"],
);
});
test("clean shell exit keeps ownership until the tab is explicitly closed", async (t) => {
const bridge = loadFreshBridge();
t.after(() => bridge.cleanup());
const closedListeners = new Set();
bridge.init({
sessions: new Map(),
electronModule: null,
terminalWorkerManager: {
onSessionClosed(listener) {
closedListeners.add(listener);
return {
dispose: () => closedListeners.delete(listener),
};
},
},
});
bridge.setPermissionMode("auto");
bridge.setVaultAgentInvoker(async () => ({
ok: true,
sessionId: "sess-opened",
hostId: "host-b",
status: "connecting",
}));
bridge.updateSessionMetadata([
{
sessionId: "sess-opened",
hostname: "10.0.0.2",
label: "server-b",
connected: true,
hostId: "host-b",
},
], "chat-1");
await bridge.dispatchBuiltinRpc("public/vault/hosts/open", {
chatSessionId: "chat-1",
hostId: "host-b",
});
// Worker reports a clean exit; auto-close may still leave the tab briefly,
// and disabled auto-close keeps it for reconnect — ownership stays.
for (const listener of closedListeners) {
listener({ sessionId: "sess-opened", reason: "exited", exitCode: 0 });
}
bridge.updateSessionMetadata([
{
sessionId: "sess-original",
hostname: "10.0.0.1",
label: "server-a",
connected: true,
hostId: "host-a",
},
], "chat-1");
assert.deepEqual(
bridge.getScopedSessionIds("chat-1").sort(),
["sess-opened", "sess-original"],
);
// Renderer tab close is the authoritative ownership drop.
for (const listener of closedListeners) {
listener({ sessionId: "sess-opened", reason: "closed", explicit: true });
}
bridge.updateSessionMetadata([
{
sessionId: "sess-original",
hostname: "10.0.0.1",
label: "server-a",
connected: true,
hostId: "host-a",
},
], "chat-1");
assert.deepEqual(bridge.getScopedSessionIds("chat-1"), ["sess-original"]);
});
test("latest metadata revision wins across multiple chat and external scopes", async (t) => {
const bridge = loadFreshBridge();
t.after(() => bridge.cleanup());
bridge.init({ sessions: new Map(), electronModule: null });
bridge.setPermissionMode("auto");
bridge.setVaultAgentInvoker(async () => ({
ok: true,
sessionId: "sess-opened",
hostId: "host-b",
status: "connecting",
}));
bridge.updateSessionMetadata([{
sessionId: "sess-opened",
connected: true,
activePortForwards: [{ ruleId: "old-forward" }],
}], "chat-stale");
bridge.updateSessionMetadata([{
sessionId: "sess-opened",
connected: true,
activePortForwards: [],
}], "chat-owner");
await bridge.dispatchBuiltinRpc("public/vault/hosts/open", {
chatSessionId: "chat-owner",
hostId: "host-b",
});
bridge.updateSessionMetadata([{
sessionId: "sess-opened",
connected: false,
activePortForwards: [],
}], "__external_mcp__");
assert.equal(bridge.getSessionMeta("sess-opened", "chat-owner")?.connected, false);
assert.deepEqual(bridge.getSessionMeta("sess-opened", "chat-owner")?.activePortForwards, []);
bridge.updateSessionMetadata([{
sessionId: "sess-opened",
connected: true,
username: "reconnected-user",
activePortForwards: [{ ruleId: "new-forward" }],
}], "chat-live");
assert.equal(bridge.getSessionMeta("sess-opened", "chat-owner")?.connected, true);
assert.equal(bridge.getSessionMeta("sess-opened", "chat-owner")?.username, "reconnected-user");
assert.deepEqual(
bridge.getSessionMeta("sess-opened", "chat-owner")?.activePortForwards,
[{ ruleId: "new-forward" }],
);
});
test("host_open finishing after an empty scope replace restores its returned session", async (t) => {
const bridge = loadFreshBridge();
t.after(() => bridge.cleanup());
bridge.init({ sessions: new Map(), electronModule: null });
bridge.setPermissionMode("auto");
let finishOpen;
bridge.setVaultAgentInvoker(() => new Promise((resolve) => {
finishOpen = resolve;
}));
bridge.updateSessionMetadata([{ sessionId: "sess-original", connected: true }], "chat-race");
const opening = bridge.dispatchBuiltinRpc("public/vault/hosts/open", {
chatSessionId: "chat-race",
hostId: "host-b",
});
bridge.updateSessionMetadata([], "chat-race");
finishOpen({
ok: true,
sessionId: "sess-opened",
hostId: "host-b",
status: "connecting",
protocol: "ssh",
host: { id: "host-b", hostname: "10.0.0.2", label: "server-b", username: "root" },
});
const opened = await opening;
assert.equal(opened.ok, true);
assert.deepEqual(bridge.getScopedSessionIds("chat-race"), ["sess-opened"]);
assert.equal(bridge.getSessionMeta("sess-opened", "chat-race")?.label, "server-b");
assert.equal(bridge.getSessionMeta("sess-opened", "chat-race")?.connected, false);
});
test("initial external scope seed selects the latest session snapshot", (t) => {
const bridge = loadFreshBridge();
t.after(() => bridge.cleanup());
bridge.init({ sessions: new Map(), electronModule: null });
bridge.updateSessionMetadata([{
sessionId: "sess-shared",
connected: true,
username: "old-user",
}], "chat-old");
bridge.updateSessionMetadata([{
sessionId: "sess-shared",
connected: false,
username: "new-user",
}], "chat-new");
const seeded = bridge.syncLiveSessionsToExternalScope();
assert.equal(seeded.seeded, true);
assert.equal(bridge.getSessionMeta("sess-shared", "__external_mcp__")?.connected, false);
assert.equal(bridge.getSessionMeta("sess-shared", "__external_mcp__")?.username, "new-user");
});
test("explicit close before host_open returns cannot revive the closed session", async (t) => {
const bridge = loadFreshBridge();
t.after(() => bridge.cleanup());
bridge.init({ sessions: new Map(), electronModule: null });
bridge.setPermissionMode("auto");
let finishOpen;
bridge.setVaultAgentInvoker(() => new Promise((resolve) => {
finishOpen = resolve;
}));
bridge.updateSessionMetadata([{ sessionId: "sess-original", connected: true }], "chat-close-race");
const opening = bridge.dispatchBuiltinRpc("public/vault/hosts/open", {
chatSessionId: "chat-close-race",
hostId: "host-b",
});
// Renderer-side host.open creates and merges the tab before returning its
// result to the main process. Simulate the user closing that tab immediately.
bridge.mergeSessionMetadata([{
sessionId: "sess-opened",
hostId: "host-b",
connected: false,
}], "chat-close-race");
bridge.forgetClosedTerminalSession("sess-opened");
finishOpen({
ok: true,
sessionId: "sess-opened",
hostId: "host-b",
status: "connecting",
protocol: "ssh",
});
assert.equal((await opening).ok, true);
bridge.updateSessionMetadata([{ sessionId: "sess-original", connected: true }], "chat-close-race");
assert.deepEqual(bridge.getScopedSessionIds("chat-close-race"), ["sess-original"]);
assert.equal(bridge.getSessionMeta("sess-opened", "chat-close-race"), null);
});
test("app live sync makes host_open session usable without External MCP or a second AI panel", async (t) => {
const bridge = loadFreshBridge();
t.after(() => bridge.cleanup());
const requests = [];
bridge.init({
sessions: new Map(),
electronModule: null,
terminalWorkerManager: {
request(channel, payload, options) {
requests.push({ channel, payload, options });
if (channel === "netcatty:portforward:list") return Promise.resolve([]);
if (channel === "netcatty:ai:exec") {
return Promise.resolve({ ok: true, stdout: "ready\n", stderr: "", exitCode: 0 });
}
throw new Error(`unexpected worker request: ${channel}`);
},
},
});
bridge.setPermissionMode("auto");
bridge.setVaultAgentInvoker(async () => ({
ok: true,
sessionId: "sess-opened",
hostId: "host-b",
status: "connecting",
protocol: "ssh",
host: { id: "host-b", hostname: "10.0.0.2", label: "server-b", username: "root" },
}));
bridge.updateSessionMetadata([{ sessionId: "sess-original", connected: true }], "chat-owner");
await bridge.dispatchBuiltinRpc("public/vault/hosts/open", {
chatSessionId: "chat-owner",
hostId: "host-b",
});
// The app-level session state observes the connection. No External MCP scope
// and no AI panel for the opened tab are involved.
bridge.updateLiveSessionMetadata([{
sessionId: "sess-opened",
hostId: "host-b",
hostname: "10.0.0.2",
label: "server-b",
username: "root",
protocol: "ssh",
connected: true,
}]);
// Returning to the original tab replaces its sidebar payload with only A.
bridge.updateSessionMetadata([{ sessionId: "sess-original", connected: true }], "chat-owner");
assert.deepEqual(
bridge.getScopedSessionIds("chat-owner").sort(),
["sess-opened", "sess-original"],
);
assert.equal(bridge.getSessionMeta("sess-opened", "chat-owner")?.connected, true);
const environment = await bridge.dispatchBuiltinRpc("netcatty/getContext", {
chatSessionId: "chat-owner",
});
const openedHost = environment.hosts.find((host) => host.sessionId === "sess-opened");
assert.equal(openedHost?.connected, true);
const executed = await bridge.dispatchBuiltinRpc("netcatty/exec", {
chatSessionId: "chat-owner",
sessionId: "sess-opened",
command: "pwd",
});
assert.deepEqual(executed, { ok: true, stdout: "ready\n", stderr: "", exitCode: 0 });
assert.equal(requests.some((entry) => entry.channel === "netcatty:ai:exec"), true);
});
test("chat cleanup during host_open closes the late-created unowned session", async (t) => {
const bridge = loadFreshBridge();
t.after(() => bridge.cleanup());
bridge.init({ sessions: new Map(), electronModule: null });
bridge.setPermissionMode("auto");
let finishOpen;
const calls = [];
bridge.setVaultAgentInvoker((op, params) => {
calls.push({ op, params });
if (op === "host.open") {
return new Promise((resolve) => {
finishOpen = resolve;
});
}
if (op === "session.close") return Promise.resolve({ ok: true, sessionId: params.sessionId });
throw new Error(`unexpected op ${op}`);
});
bridge.updateSessionMetadata([{ sessionId: "sess-original", connected: true }], "chat-deleted");
const opening = bridge.dispatchBuiltinRpc("public/vault/hosts/open", {
chatSessionId: "chat-deleted",
hostId: "host-b",
});
await bridge.cleanupScopedMetadata("chat-deleted");
// Renderer host.open can still finish its own merge after chat cleanup.
bridge.mergeSessionMetadata([{
sessionId: "sess-opened",
hostId: "host-b",
connected: false,
}], "chat-deleted");
finishOpen({
ok: true,
sessionId: "sess-opened",
hostId: "host-b",
status: "connecting",
protocol: "ssh",
});
assert.equal((await opening).ok, true);
assert.deepEqual(calls.map(({ op }) => op), ["host.open", "session.close"]);
assert.deepEqual(bridge.getScopedSessionIds("chat-deleted"), []);
assert.equal(bridge.getSessionMeta("sess-opened", "chat-deleted"), null);
});
test("failed compensating close keeps the late-created session tracked for retry", async (t) => {
const bridge = loadFreshBridge();
t.after(() => bridge.cleanup());
bridge.init({ sessions: new Map(), electronModule: null });
bridge.setPermissionMode("auto");
let finishOpen;
bridge.setVaultAgentInvoker((op) => {
if (op === "host.open") {
return new Promise((resolve) => {
finishOpen = resolve;
});
}
if (op === "session.close") {
return Promise.resolve({ ok: false, error: "renderer unavailable" });
}
throw new Error(`unexpected op ${op}`);
});
const opening = bridge.dispatchBuiltinRpc("public/vault/hosts/open", {
chatSessionId: "chat-deleted",
hostId: "host-b",
});
await bridge.cleanupScopedMetadata("chat-deleted");
bridge.mergeSessionMetadata([{
sessionId: "sess-opened",
hostId: "host-b",
connected: false,
}], "chat-deleted");
finishOpen({ ok: true, sessionId: "sess-opened", hostId: "host-b", status: "connecting" });
assert.equal((await opening).ok, true);
assert.deepEqual(bridge.getScopedSessionIds("chat-deleted"), []);
assert.equal(bridge.getSessionMeta("sess-opened", "chat-deleted"), null);
assert.equal(
bridge.reportOpenedSessionActivity({ sessionId: "sess-opened", phase: "touch" }),
true,
);
});
@@ -166,7 +166,7 @@ function createCapabilityRpcDispatcher(deps) {
: null;
const result = await handler(params);
if (capability.id === "vault.host.open" && result?.ok !== false && result?.sessionId) {
deps.onHostOpened?.(params?.chatSessionId, result.sessionId, hostOpenGeneration);
await deps.onHostOpened?.(params?.chatSessionId, result.sessionId, hostOpenGeneration, result);
}
return result;
};
@@ -169,15 +169,20 @@ test("dispatchCapabilityRpc preserves the host-open scope generation across the
assert.equal(chatSessionId, "chat-1");
return 7;
},
onHostOpened: (chatSessionId, sessionId, generation) => {
registrations.push({ chatSessionId, sessionId, generation });
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 }]);
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 () => {
@@ -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 };
@@ -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);
}
});
@@ -43,6 +43,12 @@ function createSessionOwnershipRegistry() {
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);
@@ -50,6 +56,16 @@ function createSessionOwnershipRegistry() {
}
}
/**
* 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);
@@ -65,7 +81,9 @@ function createSessionOwnershipRegistry() {
captureGeneration,
register,
validate,
listOwned,
forgetSession,
releaseScopeOwnership,
clearScope,
getTrackedGenerationCountForTests,
};
@@ -13,6 +13,18 @@ test("session ownership is isolated by AI scope", () => {
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");
@@ -23,6 +35,22 @@ test("forgetSession revokes ownership from every scope", () => {
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");
+38 -1
View File
@@ -4,6 +4,7 @@
*/
const pendingBootAborts = new Map();
const latestBootEpochs = new Map();
function normalizeBootEpoch(bootEpoch) {
if (!Number.isFinite(bootEpoch)) return undefined;
@@ -29,9 +30,13 @@ function registerPendingBootAbort(sessionId, bootEpoch) {
try { existing.controller.abort(); } catch { /* ignore */ }
}
const controller = new AbortController();
const normalized = normalizeBootEpoch(bootEpoch);
if (normalized !== undefined) {
latestBootEpochs.set(sessionId, Math.max(latestBootEpochs.get(sessionId) ?? normalized, normalized));
}
pendingBootAborts.set(sessionId, {
controller,
bootEpoch: normalizeBootEpoch(bootEpoch),
bootEpoch: normalized,
});
return controller;
}
@@ -59,6 +64,34 @@ function abortPendingBoot(sessionId, bootEpoch) {
return true;
}
function hasPendingBootAfter(sessionId, bootEpoch) {
if (!sessionId) return false;
const pending = pendingBootAborts.get(sessionId);
const requested = normalizeBootEpoch(bootEpoch);
return Boolean(
pending
&& requested !== undefined
&& pending.bootEpoch !== undefined
&& pending.bootEpoch > requested
);
}
function hasNewerBootEpoch(sessionId, bootEpoch) {
if (!sessionId) return false;
const requested = normalizeBootEpoch(bootEpoch);
const latest = latestBootEpochs.get(sessionId);
return requested !== undefined && latest !== undefined && latest > requested;
}
function forgetBootEpoch(sessionId, bootEpoch) {
if (!sessionId) return;
const requested = normalizeBootEpoch(bootEpoch);
const latest = latestBootEpochs.get(sessionId);
if (requested === undefined || latest === undefined || latest <= requested) {
latestBootEpochs.delete(sessionId);
}
}
function clearPendingBootAbort(sessionId, controller) {
if (!sessionId || !controller) return;
const pending = pendingBootAborts.get(sessionId);
@@ -161,6 +194,7 @@ function claimSessionSlot(sessions, sessionId, session, bootEpoch) {
}
if (normalized !== undefined) {
session.bootEpoch = normalized;
latestBootEpochs.set(sessionId, Math.max(latestBootEpochs.get(sessionId) ?? normalized, normalized));
}
let displaced;
// When a newer boot replaces an older registry entry, mark and return the
@@ -197,6 +231,9 @@ module.exports = {
claimSessionSlot,
clearPendingBootAbort,
disposeDisplacedSessionResources,
forgetBootEpoch,
hasPendingBootAfter,
hasNewerBootEpoch,
normalizeBootEpoch,
registerPendingBootAbort,
sessionMatchesBootEpoch,
@@ -21,17 +21,26 @@ test("pending boot abort cancels older generations but not newer ones", () => {
const {
abortPendingBoot,
clearPendingBootAbort,
forgetBootEpoch,
hasNewerBootEpoch,
registerPendingBootAbort,
hasPendingBootAfter,
} = require("./sessionBootEpoch.cjs");
const older = registerPendingBootAbort("s-pending", 2);
const newer = registerPendingBootAbort("s-pending", 4);
assert.equal(older.signal.aborted, true);
assert.equal(newer.signal.aborted, false);
assert.equal(abortPendingBoot("s-pending", 3), false);
assert.equal(hasPendingBootAfter("s-pending", 3), true);
assert.equal(hasNewerBootEpoch("s-pending", 3), true);
assert.equal(newer.signal.aborted, false);
assert.equal(abortPendingBoot("s-pending", 4), true);
assert.equal(hasPendingBootAfter("s-pending", 3), false);
assert.equal(hasNewerBootEpoch("s-pending", 3), true);
assert.equal(newer.signal.aborted, true);
clearPendingBootAbort("s-pending", newer);
forgetBootEpoch("s-pending", 4);
assert.equal(hasNewerBootEpoch("s-pending", 3), false);
});
test("claimSessionSlot replaces an older boot epoch and marks it superseded", () => {
+33 -3
View File
@@ -932,8 +932,14 @@ function createFileOpsApi(ctx) {
}
}
// Method 2: SFTP realpath('.') — skip if result is '/' for non-root users
// because some SFTP servers start in '/' rather than the user's home
// Method 2: SFTP realpath('.'). A virtual/chroot SFTP server (including
// bastion products such as JumpServer) can legitimately expose '/' as
// the authenticated user's root even when SSH exec channels are denied.
//
// Accept non-root absolute paths immediately. Accept '/' only when it is
// actually listable so we do not suppress renderer candidate probing
// (/home/<user>, /root) on servers that merely start cwd at '/' without
// granting readdir on the real filesystem root.
try {
const sftp = await requireSftpChannel(client, {
signal,
@@ -942,9 +948,33 @@ function createFileOpsApi(ctx) {
throwIfAborted(signal);
const absPath = await realpathAsync(sftp, ".");
throwIfAborted(signal);
if (absPath && absPath !== "/") {
if (absPath && absPath.startsWith("/") && absPath !== "/") {
return { success: true, homeDir: absPath };
}
if (absPath === "/") {
try {
if (typeof readdirAsync === "function") {
await readdirAsync(sftp, "/");
} else if (sftp && typeof sftp.readdir === "function") {
await new Promise((resolve, reject) => {
sftp.readdir("/", (err, items) => {
if (err) reject(err);
else resolve(items || []);
});
});
} else {
// No list probe available — keep virtual-root acceptance from #2934.
return { success: true, homeDir: "/" };
}
throwIfAborted(signal);
return { success: true, homeDir: "/" };
} catch (listErr) {
if (signal?.aborted) {
throw listErr;
}
// Non-listable root: fall through so candidate probing can run.
}
}
} catch (err) {
if (signal?.aborted) {
throw err;
@@ -0,0 +1,68 @@
const assert = require("node:assert/strict");
const test = require("node:test");
const { createFileOpsApi } = require("./fileOps.cjs");
test("home discovery accepts a virtual SFTP root when SSH exec is unavailable", async () => {
const channel = {};
const listed = [];
const api = createFileOpsApi({
sftpClients: new Map([["jumpserver", { sftp: channel }]]),
throwIfAborted() {},
requireSftpChannel: async () => channel,
realpathAsync: async (resolvedChannel, remotePath) => {
assert.equal(resolvedChannel, channel);
assert.equal(remotePath, ".");
return "/";
},
readdirAsync: async (resolvedChannel, remotePath) => {
listed.push([resolvedChannel, remotePath]);
return [{ filename: "data" }];
},
});
const result = await api.getSftpHomeDir(null, { sftpId: "jumpserver" });
assert.deepEqual(result, { success: true, homeDir: "/" });
assert.deepEqual(listed, [[channel, "/"]]);
});
test("home discovery rejects non-listable root so candidate probing can run", async () => {
const channel = {};
const api = createFileOpsApi({
sftpClients: new Map([["restricted", { sftp: channel }]]),
throwIfAborted() {},
requireSftpChannel: async () => channel,
realpathAsync: async () => "/",
readdirAsync: async () => {
const error = new Error("Permission denied");
error.code = "EACCES";
throw error;
},
});
const result = await api.getSftpHomeDir(null, { sftpId: "restricted" });
assert.equal(result.success, false);
assert.match(result.error || "", /Could not determine home directory/);
});
test("home discovery still accepts non-root realpath without listing", async () => {
const channel = {};
let readdirCalls = 0;
const api = createFileOpsApi({
sftpClients: new Map([["normal", { sftp: channel }]]),
throwIfAborted() {},
requireSftpChannel: async () => channel,
realpathAsync: async () => "/home/deploy",
readdirAsync: async () => {
readdirCalls += 1;
return [];
},
});
const result = await api.getSftpHomeDir(null, { sftpId: "normal" });
assert.deepEqual(result, { success: true, homeDir: "/home/deploy" });
assert.equal(readdirCalls, 0);
});
+35 -1
View File
@@ -1975,6 +1975,21 @@ function clearSessionPtyBuffer(event, payload) {
}
}
function shouldRevokeOpenedSessionOwnership(payload) {
// Disconnect / reconnect tear down the transport but keep the tab and session
// id. Those closes must not drop host_open ownership.
return payload?.retainOwnership !== true;
}
function reportOpenedSessionClosed(sessionId, payload) {
if (!shouldRevokeOpenedSessionOwnership(payload)) return;
try {
reportOpenedSessionActivity?.({ sessionId, phase: "closed" });
} catch {
// Ownership cleanup must not interfere with session teardown.
}
}
/**
* Close a session
*/
@@ -1982,6 +1997,9 @@ function closeSession(event, payload) {
const session = sessions.get(payload.sessionId);
const {
abortPendingBoot,
forgetBootEpoch,
hasNewerBootEpoch,
hasPendingBootAfter,
sessionMatchesBootEpoch,
} = require("./sessionBootEpoch.cjs");
const passphraseHandler = require("./passphraseHandler.cjs");
@@ -1995,8 +2013,22 @@ function closeSession(event, payload) {
if (session && !sessionMatchesBootEpoch(session, payload?.bootEpoch)) {
return { skipped: true, reason: "boot-epoch-mismatch" };
}
if (!session) {
// A direct-mode backend can remove a naturally exited session before the
// renderer closes its tab. That later close is still authoritative for AI
// ownership, unless it belongs to an older boot than a pending reconnect.
if (
hasPendingBootAfter(payload.sessionId, payload?.bootEpoch)
|| hasNewerBootEpoch(payload.sessionId, payload?.bootEpoch)
) {
return { skipped: true, reason: "boot-epoch-mismatch" };
}
releaseAttachedSessionState(payload.sessionId);
reportOpenedSessionClosed(payload.sessionId, payload);
forgetBootEpoch(payload.sessionId, payload?.bootEpoch);
return { closed: false, reason: "missing" };
}
releaseAttachedSessionState(payload.sessionId);
if (!session) return { closed: false, reason: "missing" };
terminalFlowPauseArbiter.clearSession(payload.sessionId);
session.closed = true;
fanoutSessionLifecycleEvent(
@@ -2068,6 +2100,8 @@ function closeSession(event, payload) {
}
ptyProcessTree.unregisterPid(payload.sessionId);
sessions.delete(payload.sessionId);
forgetBootEpoch(payload.sessionId, payload?.bootEpoch);
reportOpenedSessionClosed(payload.sessionId, payload);
return { closed: true };
}
@@ -30,6 +30,92 @@ test("renderer terminal input reports activity for the matching session", () =>
]);
});
test("direct-mode close reports ownership cleanup after the backend already exited", () => {
const activity = [];
terminalBridge.init({
sessions: new Map(),
electronModule: {},
reportOpenedSessionActivity: (event) => activity.push(event),
});
const result = terminalBridge.closeSession({ sender: {} }, {
sessionId: "already-exited",
bootEpoch: 3,
});
assert.deepEqual(result, { closed: false, reason: "missing" });
assert.deepEqual(activity, [{ sessionId: "already-exited", phase: "closed" }]);
});
test("direct-mode disconnect close keeps host_open ownership for reconnect", () => {
const activity = [];
const sessions = new Map([
["session-keep", {
proc: { kill() {} },
webContentsId: 1,
}],
]);
terminalBridge.init({
sessions,
electronModule: { webContents: { fromId: () => ({ isDestroyed: () => true, send() {} }) } },
reportOpenedSessionActivity: (event) => activity.push(event),
});
const result = terminalBridge.closeSession({ sender: {} }, {
sessionId: "session-keep",
retainOwnership: true,
});
assert.deepEqual(result, { closed: true });
assert.equal(sessions.has("session-keep"), false);
assert.deepEqual(activity, []);
});
test("direct-mode disconnect close of an already-exited session keeps ownership", () => {
const activity = [];
terminalBridge.init({
sessions: new Map(),
electronModule: {},
reportOpenedSessionActivity: (event) => activity.push(event),
});
const result = terminalBridge.closeSession({ sender: {} }, {
sessionId: "already-exited",
bootEpoch: 3,
retainOwnership: true,
});
assert.deepEqual(result, { closed: false, reason: "missing" });
assert.deepEqual(activity, []);
});
test("stale direct-mode close cannot clean up a newer same-id boot", () => {
const activity = [];
const { claimSessionSlot } = require("./sessionBootEpoch.cjs");
const lifecycle = new Map();
claimSessionSlot(lifecycle, "reconnected", {}, 4);
lifecycle.delete("reconnected");
terminalBridge.init({
sessions: new Map(),
electronModule: {},
reportOpenedSessionActivity: (event) => activity.push(event),
});
const stale = terminalBridge.closeSession({ sender: {} }, {
sessionId: "reconnected",
bootEpoch: 3,
});
assert.deepEqual(stale, { skipped: true, reason: "boot-epoch-mismatch" });
assert.deepEqual(activity, []);
const current = terminalBridge.closeSession({ sender: {} }, {
sessionId: "reconnected",
bootEpoch: 4,
});
assert.deepEqual(current, { closed: false, reason: "missing" });
assert.deepEqual(activity, [{ sessionId: "reconnected", phase: "closed" }]);
});
test("only a submitted non-sensitive user command arms cwd recovery", () => {
const stream = { write() { return true; } };
const session = { stream, webContentsId: 1, blockUntargetedCwdProbe: true };
+32 -6
View File
@@ -1125,13 +1125,27 @@ function createTerminalWorkerManager(options = {}) {
}
}
function notifySessionClosed(sessionId, reason) {
function notifySessionClosed(sessionId, reason, options = {}) {
if (!sessionId) return;
const explicit = options?.explicit === true;
const event = explicit
? { sessionId, reason, explicit: true }
: { sessionId, reason };
for (const listener of [...sessionClosedListeners]) {
try { listener(Object.freeze({ sessionId, reason })); } catch {}
try { listener(Object.freeze(event)); } catch {}
}
}
function notifyRendererSessionClosed(sessionId, payload) {
// Disconnect / reconnect keep the tab and session id, so they must not
// look like an explicit tab close to host_open ownership.
if (payload?.retainOwnership === true) {
notifySessionClosed(sessionId, "closed");
return;
}
notifySessionClosed(sessionId, "closed", { explicit: true });
}
function drainOutputSession(sessionId, requestId) {
if (!sessionId || !requestId || !outputPortReady.has(sessionId)) return false;
const worker = ensureStarted();
@@ -1473,7 +1487,9 @@ function createTerminalWorkerManager(options = {}) {
clearAttachHome(sessionId);
terminalOutputChannel?.closeSession?.(sessionId);
suppressedPendingOutputSessions.add(sessionId);
notifySessionClosed(sessionId, message.payload?.reason || "superseded");
// Always label superseded exits as such — payload reason may be
// "closed"/"error" and must not be mistaken for an authoritative end.
notifySessionClosed(sessionId, "superseded");
const targets = new Set([displayWebContentsId, homeWebContentsId]);
if (onRendererEvent) {
for (const webContentsId of targets) {
@@ -1502,7 +1518,7 @@ function createTerminalWorkerManager(options = {}) {
finishPendingSessionStart(originEntry);
pending.delete(message.originRequestId);
originEntry.reject(new Error("Terminal session was superseded by a newer start request"));
notifySessionClosed(sessionId, message.payload?.reason);
notifySessionClosed(sessionId, "superseded");
return;
}
const displayWebContentsId =
@@ -1752,6 +1768,11 @@ function createTerminalWorkerManager(options = {}) {
&& payload?.sessionId
&& closedSessions.has(payload.sessionId)
&& !pendingSessionStartSequences.has(payload.sessionId)) {
// Worker already tombstoned the session (clean exit / crash). A later
// renderer close still has to notify host listeners as explicit so
// auto-close can drop host_open ownership, unless this is a disconnect
// that keeps the tab for reconnect.
notifyRendererSessionClosed(payload.sessionId, payload);
return Promise.resolve(undefined);
}
const requestId = randomUUID();
@@ -1813,7 +1834,9 @@ function createTerminalWorkerManager(options = {}) {
finishPendingSessionStart(entry);
entry?.reject(error);
}
if (notifyClosedAfterPost) notifySessionClosed(payload.sessionId, "closed");
if (notifyClosedAfterPost) {
notifyRendererSessionClosed(payload.sessionId, payload);
}
return promise;
}
@@ -1900,6 +1923,7 @@ function createTerminalWorkerManager(options = {}) {
&& closedSessions.has(payload.sessionId)
&& !pendingSessionStartSequences.has(payload.sessionId)) {
closeOutputSession(payload.sessionId);
notifyRendererSessionClosed(payload.sessionId, payload);
return;
}
if (channel === "netcatty:close" && payload?.sessionId) {
@@ -1921,7 +1945,9 @@ function createTerminalWorkerManager(options = {}) {
retireWorkerAfterIpcFailure(worker, error);
postError = error;
}
if (shouldNotifyClosed) notifySessionClosed(payload.sessionId, "closed");
if (shouldNotifyClosed) {
notifyRendererSessionClosed(payload.sessionId, payload);
}
if (postError) throw postError;
return;
}
+107 -12
View File
@@ -2162,16 +2162,104 @@ test("explicit close notifies host session lifecycle exactly once before the wor
await start;
manager.send("netcatty:close", { sessionId: "session-1" }, { webContentsId: 7 });
assert.deepEqual(closed, [{ sessionId: "session-1", reason: "closed" }]);
assert.deepEqual(closed, [{ sessionId: "session-1", reason: "closed", explicit: true }]);
child.emit("message", {
kind: "renderer-event",
webContentsId: 7,
channel: "netcatty:exit",
payload: { sessionId: "session-1", reason: "exited" },
});
assert.deepEqual(closed, [{ sessionId: "session-1", reason: "closed", explicit: true }]);
});
test("retainOwnership close does not mark host_open cleanup as explicit", async () => {
const child = new FakeChild();
const closed = [];
const manager = createTerminalWorkerManager({
utilityProcess: { fork: () => child },
terminalOutputChannel: { openSession: () => true, closeSession() {} },
electronModule: { webContents: { fromId: (id) => ({ id, isDestroyed: () => false, send() {} }) } },
workerScriptPath: "/worker.cjs",
});
manager.onSessionClosed((event) => closed.push(event));
const start = manager.request("netcatty:local:start", {}, { webContentsId: 7 });
child.emit("message", {
kind: "response",
requestId: child.messages[0].requestId,
result: { sessionId: "session-1" },
});
await start;
manager.send("netcatty:close", {
sessionId: "session-1",
retainOwnership: true,
}, { webContentsId: 7 });
assert.deepEqual(closed, [{ sessionId: "session-1", reason: "closed" }]);
});
async function startWorkerSessionForLifecycle(manager, child) {
const start = manager.request("netcatty:local:start", {}, { webContentsId: 7 });
child.emit("message", {
kind: "response",
requestId: child.messages[0].requestId,
result: { sessionId: "session-1" },
});
await start;
}
function createLifecycleManager(child, closed) {
const manager = createTerminalWorkerManager({
utilityProcess: { fork: () => child },
terminalOutputChannel: { openSession: () => true, closeSession() {} },
electronModule: { webContents: { fromId: (id) => ({ id, isDestroyed: () => false, send() {} }) } },
workerScriptPath: "/worker.cjs",
});
manager.onSessionClosed((event) => closed.push(event));
return manager;
}
test("fire-and-forget close after a worker exit still emits an explicit lifecycle event", async () => {
const child = new FakeChild();
const closed = [];
const manager = createLifecycleManager(child, closed);
await startWorkerSessionForLifecycle(manager, child);
child.emit("message", {
kind: "renderer-event",
webContentsId: 7,
channel: "netcatty:exit",
payload: { sessionId: "session-1", reason: "exited", exitCode: 0 },
});
assert.deepEqual(closed, [{ sessionId: "session-1", reason: "exited" }]);
manager.send("netcatty:close", { sessionId: "session-1" }, { webContentsId: 7 });
assert.deepEqual(closed, [
{ sessionId: "session-1", reason: "exited" },
{ sessionId: "session-1", reason: "closed", explicit: true },
]);
});
test("awaited close after a worker exit still emits an explicit lifecycle event", async () => {
const child = new FakeChild();
const closed = [];
const manager = createLifecycleManager(child, closed);
await startWorkerSessionForLifecycle(manager, child);
child.emit("message", {
kind: "renderer-event",
webContentsId: 7,
channel: "netcatty:exit",
payload: { sessionId: "session-1", reason: "exited", exitCode: 0 },
});
assert.deepEqual(closed, [{ sessionId: "session-1", reason: "exited" }]);
await manager.request("netcatty:close:await", { sessionId: "session-1" }, { webContentsId: 7 });
assert.deepEqual(closed, [
{ sessionId: "session-1", reason: "exited" },
{ sessionId: "session-1", reason: "closed", explicit: true },
]);
});
test("a duplicate exit from an old session generation cannot close a reconnected session", async () => {
const child = new FakeChild();
const closed = [];
@@ -2357,7 +2445,7 @@ test("an old-generation exit after explicit close cannot cancel a pending reconn
await reconnect;
assert.equal(manager.hasOpenSession("session-1"), true);
assert.deepEqual(closed, [{ sessionId: "session-1", reason: "closed" }]);
assert.deepEqual(closed, [{ sessionId: "session-1", reason: "closed", explicit: true }]);
assert.deepEqual(routed, [{ sessionId: "session-1", data: "NEW-EARLY" }]);
});
@@ -2404,7 +2492,7 @@ test("a closed pending start response records its generation before a later reco
await second;
assert.equal(manager.hasOpenSession("session-1"), true);
assert.deepEqual(closed, [{ sessionId: "session-1", reason: "closed" }]);
assert.deepEqual(closed, [{ sessionId: "session-1", reason: "closed", explicit: true }]);
});
test("a much older exit cannot cancel a reconnect after multiple generations", async () => {
@@ -3092,7 +3180,10 @@ test("closing a crashed session is idempotent until a new worker reuses its id",
}
await close;
assert.equal(children.length, 1);
assert.deepEqual(closed, [{ sessionId: "session-1", reason: "worker-exit" }]);
assert.deepEqual(closed, [
{ sessionId: "session-1", reason: "worker-exit" },
{ sessionId: "session-1", reason: "closed", explicit: true },
]);
const secondStart = manager.request("netcatty:local:start", {}, { webContentsId: 7 });
const secondChild = children[1];
@@ -3105,6 +3196,7 @@ test("closing a crashed session is idempotent until a new worker reuses its id",
secondChild.emit("exit", 1);
assert.deepEqual(closed, [
{ sessionId: "session-1", reason: "worker-exit" },
{ sessionId: "session-1", reason: "closed", explicit: true },
{ sessionId: "session-1", reason: "worker-exit" },
]);
});
@@ -3615,7 +3707,7 @@ test("an internal same-id replacement exit retires only the old generation", asy
data: "NEW-EARLY",
webContentsId: 8,
}]);
assert.deepEqual(closed, [{ sessionId: "session-1", reason: "closed" }]);
assert.deepEqual(closed, [{ sessionId: "session-1", reason: "superseded" }]);
assert.equal(outputOpen, true);
assert.equal(
child.messages.some((message) => (
@@ -3690,7 +3782,7 @@ test("a failed replacement is not reported closed again when the worker exits",
await assert.rejects(replacement, /start failed/u);
child.emit("exit", 1);
assert.deepEqual(closed, [{ sessionId: "session-1", reason: "closed" }]);
assert.deepEqual(closed, [{ sessionId: "session-1", reason: "superseded" }]);
});
test("manager and runtime preserve a completed same-id replacement end to end", async () => {
@@ -3770,7 +3862,7 @@ test("manager and runtime preserve a completed same-id replacement end to end",
{ sessionId: "session-1", data: "OLD", webContentsId: 7 },
{ sessionId: "session-1", data: "NEW-EARLY", webContentsId: 8 },
]);
assert.deepEqual(closed, [{ sessionId: "session-1", reason: "closed" }]);
assert.deepEqual(closed, [{ sessionId: "session-1", reason: "superseded" }]);
assert.equal(outputWebContentsId, 8);
assert.equal(
child.messages.some((message) => (
@@ -4183,7 +4275,7 @@ test("a superseded start failure cannot cancel the queued same-id restart", asyn
await second;
assert.equal(manager.hasOpenSession("session-1"), true);
assert.deepEqual(closed, [{ sessionId: "session-1", reason: "failed" }]);
assert.deepEqual(closed, [{ sessionId: "session-1", reason: "superseded" }]);
});
test("a failed reconnect after worker exit keeps later close cleanup idempotent", async () => {
@@ -4237,7 +4329,10 @@ test("a failed reconnect after worker exit keeps later close cleanup idempotent"
manager.send("netcatty:close", { sessionId: "session-1" }, { webContentsId: 7 });
assert.equal(children.length, 2);
assert.deepEqual(closed, [{ sessionId: "session-1", reason: "worker-exit" }]);
assert.deepEqual(closed, [
{ sessionId: "session-1", reason: "worker-exit" },
{ sessionId: "session-1", reason: "closed", explicit: true },
]);
});
test("a same-id reconnect that outputs before another worker exit closes its new lifecycle", async () => {
@@ -4350,7 +4445,7 @@ test("closing a same-id reconnect before its response closes the new lifecycle o
assert.equal(closeMessage.channel, "netcatty:close");
assert.deepEqual(closed, [
{ sessionId: "session-1", reason: "worker-exit" },
{ sessionId: "session-1", reason: "closed" },
{ sessionId: "session-1", reason: "closed", explicit: true },
]);
secondChild.emit("message", {
@@ -4362,7 +4457,7 @@ test("closing a same-id reconnect before its response closes the new lifecycle o
secondChild.emit("message", { kind: "session-exit", sessionId: "session-1", exitCode: 0 });
assert.deepEqual(closed, [
{ sessionId: "session-1", reason: "worker-exit" },
{ sessionId: "session-1", reason: "closed" },
{ sessionId: "session-1", reason: "closed", explicit: true },
]);
});
@@ -4675,7 +4770,7 @@ test("an awaited close still notifies lifecycle cleanup when worker IPC is alrea
);
await assert.rejects(close, /IPC closed/u);
assert.deepEqual(closed, [{ sessionId: "session-1", reason: "closed" }]);
assert.deepEqual(closed, [{ sessionId: "session-1", reason: "closed", explicit: true }]);
assert.equal(manager.hasOpenSession("session-1"), false);
});
+4
View File
@@ -529,6 +529,7 @@ function createPreloadApi(ctx) {
const payload = {
sessionId,
...(Number.isFinite(options?.bootEpoch) ? { bootEpoch: Number(options.bootEpoch) } : {}),
...(options?.retainOwnership === true ? { retainOwnership: true } : {}),
};
const epochScoped = Number.isFinite(options?.bootEpoch);
// Unscoped closes keep the historical sync listener teardown so reconnect
@@ -1720,6 +1721,9 @@ function createPreloadApi(ctx) {
aiMcpUpdateSessions: async (sessions, chatSessionId) => {
return ipcRenderer.invoke("netcatty:ai:mcp:update-sessions", { sessions, chatSessionId });
},
aiMcpUpdateLiveSessions: async (sessions) => {
return ipcRenderer.invoke("netcatty:ai:mcp:update-live-sessions", { sessions });
},
aiMcpMergeSessions: async (sessions, chatSessionId) => {
return ipcRenderer.invoke("netcatty:ai:mcp:merge-sessions", { sessions, chatSessionId });
},
+15
View File
@@ -107,6 +107,21 @@ declare global {
hostChain?: Array<{ hostId: string; label?: string; hostname?: string }>;
activePortForwards?: Array<{ ruleId: string; label?: string; type?: string; localPort?: number; status?: string }>;
}>, chatSessionId?: string): Promise<{ ok: boolean }>;
/** Update the app-owned live session snapshot used by existing AI scopes. */
aiMcpUpdateLiveSessions?(sessions: Array<{
sessionId: string;
hostId?: string;
hostname: string;
label: string;
os?: string;
username?: string;
protocol?: string;
shellType?: string;
deviceType?: string;
connected: boolean;
hostChain?: Array<{ hostId: string; label?: string; hostname?: string }>;
activePortForwards?: Array<{ ruleId: string; label?: string; type?: string; localPort?: number; status?: string }>;
}>): Promise<{ ok: boolean; count?: number; error?: string }>;
/** Merge sessions into a chat scope without dropping existing entries. */
aiMcpMergeSessions?(sessions: Array<{
sessionId: string;
+1 -1
View File
@@ -363,7 +363,7 @@ declare global {
respondTerminalOutputDrain?(requestId: string): void;
notifyTerminalSessionDisplayReady?(sessionId: string): void;
ackSessionFlow(sessionId: string, bytes: number): void;
closeSession(sessionId: string, options?: { bootEpoch?: number }): void | Promise<void>;
closeSession(sessionId: string, options?: { bootEpoch?: number; retainOwnership?: boolean }): void | Promise<void>;
/** Move a live session's output port to this renderer (same PTY). */
rebindTerminalSessionOutput?(sessionId: string, authorization: string): Promise<{
success: boolean;