mirror of
https://github.com/binaricat/Netcatty.git
synced 2026-10-31 02:42:31 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
81dcf849fb | ||
|
|
1dfe1379cd | ||
|
|
7ac878d00e | ||
|
|
f90f40e624 | ||
|
|
2c2d7e0b55 | ||
|
|
700d603e64 | ||
|
|
f2b3facd9f | ||
|
|
7f08bec0b9 | ||
|
|
bbd4c698a5 | ||
|
|
60e057318f | ||
|
|
9671468412 | ||
|
|
e4f9303de5 | ||
|
|
c8a393f12b | ||
|
|
7114288d2f | ||
|
|
0382c089e1 | ||
|
|
f8c7dd44db |
No files matched your search
@@ -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");
|
||||
|
||||
@@ -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']);
|
||||
});
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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 };
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
|
||||
@@ -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 });
|
||||
},
|
||||
|
||||
Vendored
+15
@@ -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
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user