Compare commits

...
16 changed files with 851 additions and 20 deletions

No files matched your search

+6
View File
@@ -53,6 +53,7 @@ import {
} from '../../domain/terminalAppearance';
import { selectConnectionLogForTerminalDataCapture } from '../../domain/connectionLog';
import { collectSessionIds } from '../../domain/workspace';
import { useHostOpenSessionReuse } from '../state/useHostOpenSessionReuse';
import type { PaneMagnificationController } from '../../domain/paneMagnification';
import { resolveCloseIntent } from '../state/resolveCloseIntent';
import { resolveSnippetsShortcutIntent } from '../state/resolveSnippetsShortcutIntent';
@@ -1432,6 +1433,10 @@ export function AppSideEffects() {
return { ok: true as const, sessionId, host };
}, [handleConnectToHost]);
const findExistingSessionForHostForVaultAgent = useHostOpenSessionReuse({
sessions, hosts, keys, identities, resolveEffectiveHost,
});
const closeSessionForVaultAgent = useCallback((sessionId: string) => {
if (!sessions.some((session) => session.id === sessionId)) {
return { ok: false as const, error: `Session "${sessionId}" was not found.` };
@@ -1474,6 +1479,7 @@ export function AppSideEffects() {
stopTunnel,
stopRuleTunnels,
openHost: openHostForVaultAgent,
findExistingSessionForHost: findExistingSessionForHostForVaultAgent,
closeSession: closeSessionForVaultAgent,
getScriptSessionMeta: (sessionId) => sessions.find((session) => session.id === sessionId),
});
@@ -0,0 +1,215 @@
import assert from 'node:assert/strict';
import { test } from 'node:test';
import React from 'react';
import { act, create, type ReactTestRenderer } from 'react-test-renderer';
import type { Host, Identity, SSHKey, TerminalSession } from '../../domain/models';
import { updateRestoredSessionStatusState } from './sessionRestoreState';
import { resolveTerminalSessionHost } from '../../domain/terminalHostResolution';
import { createHostTerminalSession } from './sessionFactories';
import { useHostOpenSessionReuse } from './useHostOpenSessionReuse';
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
const effective = (host: Host) => host;
const host = (extra: Partial<Host> = {}): Host => ({
id: 'host', hostname: 'original.invalid', username: 'user', label: 'Original', os: 'linux', tags: [], protocol: 'ssh', ...extra,
});
async function harness(initial: Host, extra: { hosts?: Host[]; keys?: SSHKey[]; identities?: Identity[]; sessions?: TerminalSession[] } = {}) {
let hosts = [initial, ...(extra.hosts ?? [])];
let keys = extra.keys ?? [];
let identities = extra.identities ?? [];
let sessions: TerminalSession[] = extra.sessions ?? [{ ...createHostTerminalSession('tab', initial), status: 'connected' }];
let find: ReturnType<typeof useHostOpenSessionReuse>;
function Harness() {
find = useHostOpenSessionReuse({ sessions, hosts, keys, identities, resolveEffectiveHost: effective });
return null;
}
let renderer: ReactTestRenderer;
await act(async () => { renderer = create(React.createElement(Harness)); });
return {
find: () => find(initial.id),
async update(input: { host?: Host; hosts?: Host[]; keys?: SSHKey[]; identities?: Identity[]; sessions?: TerminalSession[] }) {
hosts = input.hosts ?? (input.host ? [input.host, ...hosts.slice(1)] : hosts);
keys = input.keys ?? keys;
identities = input.identities ?? identities;
sessions = input.sessions ?? sessions;
await act(async () => renderer.update(React.createElement(Harness)));
},
close: () => act(async () => renderer.unmount()),
};
}
for (const [field, value] of [
['hostname', 'replacement.invalid'], ['username', 'other-user'], ['port', 2222],
['protocol', 'telnet'], ['password', 'new-password'], ['authMethod', 'key'],
['hostChain', { hostIds: ['jump'] }],
['proxyConfig', { type: 'socks5', host: 'proxy.invalid', port: 1080 }],
] as const) {
test(`a saved ${field} edit cannot reuse the old tab`, async (t) => {
const original = host({ password: 'old-password', authMethod: 'password' });
const api = await harness(original, { hosts: [host({ id: 'jump', hostname: 'jump.invalid' })] });
t.after(api.close);
assert.equal((await api.find())?.sessionId, 'tab');
await api.update({ host: { ...original, [field]: value } });
assert.equal(await api.find(), undefined);
});
}
test('renaming the target and jump host keeps the same tab eligible', async (t) => {
const original = host({ hostChain: { hostIds: ['jump'] } });
const jump = host({ id: 'jump', hostname: 'jump.invalid' });
const api = await harness(original, { hosts: [jump] });
t.after(api.close);
await api.update({ hosts: [{ ...original, label: 'Renamed', fontSize: 16, notes: 'Updated note' }, { ...jump, label: 'Renamed jump' }] });
assert.equal((await api.find())?.sessionId, 'tab');
});
test('changing a jump endpoint or its password rejects the original connection', async (t) => {
const original = host({ hostChain: { hostIds: ['jump'] } });
const jump = host({ id: 'jump', hostname: 'jump.invalid', password: 'old' });
const api = await harness(original, { hosts: [jump] });
t.after(api.close);
for (const changed of [{ ...jump, hostname: 'new-jump.invalid' }, { ...jump, password: 'new' }]) {
await api.update({ hosts: [original, changed] });
assert.equal(await api.find(), undefined);
}
});
test('rotating a referenced identity or key keeps stable IDs from masking auth changes', async (t) => {
const identity: Identity = { id: 'identity', label: 'Login', username: 'user', password: 'old', authMethod: 'password', created: 0 };
const original = host({ identityId: identity.id });
const api = await harness(original, { identities: [identity] });
t.after(api.close);
assert.equal((await api.find())?.sessionId, 'tab');
await api.update({ identities: [{ ...identity, password: 'rotated' }] });
assert.equal(await api.find(), undefined);
const key: SSHKey = { id: 'key', label: 'Key', type: 'ED25519', privateKey: 'old-key', source: 'generated', category: 'key', created: 0 };
const keyHost = host({ identityFileId: key.id, authMethod: 'key' });
const keyApi = await harness(keyHost, { keys: [key] });
t.after(keyApi.close);
assert.equal((await keyApi.find())?.sessionId, 'tab');
await keyApi.update({ keys: [{ ...key, privateKey: 'rotated-key' }] });
assert.equal(await keyApi.find(), undefined);
});
for (const [field, value] of [
['baudRate', 19200], ['dataBits', 7], ['stopBits', 2], ['parity', 'even'], ['flowControl', 'rts/cts'],
] as const) {
test(`a serial ${field} edit must not reuse the opening parameters`, async (t) => {
const original = host({ protocol: 'serial', hostname: '/dev/tty.test', username: '', serialConfig: { path: '/dev/tty.test', baudRate: 9600, dataBits: 8, stopBits: 1, parity: 'none', flowControl: 'none' } });
const api = await harness(original);
t.after(api.close);
assert.equal((await api.find())?.sessionId, 'tab');
await api.update({ host: { ...original, serialConfig: { ...original.serialConfig!, [field]: value } } });
assert.equal(await api.find(), undefined);
});
}
test('unchanged legacy serial defaults still match their opening snapshot', async (t) => {
const original = host({ protocol: 'serial', hostname: '/dev/tty.test', username: '', port: 9600 });
const api = await harness(original);
t.after(api.close);
assert.equal((await api.find())?.sessionId, 'tab');
});
test('the captured serial settings come from the opening session even if the vault was already edited', async (t) => {
const original = host({ protocol: 'serial', hostname: '/dev/tty.test', username: '', serialConfig: { path: '/dev/tty.test', baudRate: 9600 } });
const session: TerminalSession = { ...createHostTerminalSession('tab', original), status: 'connected' };
const api = await harness({ ...original, serialConfig: { ...original.serialConfig!, baudRate: 19200 } }, { sessions: [session] });
t.after(api.close);
assert.equal(await api.find(), undefined);
});
for (const changedField of ['configuration', 'credentialId'] as const) {
test(`a restored plugin tab keeps its opening ${changedField} after manual reconnect`, async (t) => {
const providerId = 'com.example.transport.connection';
const original = host({ protocol: `plugin:${providerId}`, pluginConnection: { providerId, configuration: { endpoint: 'original.invalid' }, credentialId: 'original-credential-reference' } });
const edited = { ...original, pluginConnection: { ...original.pluginConnection!, [changedField]: changedField === 'configuration' ? { endpoint: 'replacement.invalid' } : 'replacement-credential-reference' } };
const restored: TerminalSession = { ...createHostTerminalSession('tab', original), status: 'disconnected', restoreState: 'restored-disconnected' };
const api = await harness(edited, { sessions: [restored] });
t.after(api.close);
assert.equal(await api.find(), undefined);
const reconnecting = updateRestoredSessionStatusState([restored], restored.id, 'connecting');
const actual = resolveTerminalSessionHost({ session: reconnecting[0], hosts: [edited], groupConfigs: [], proxyProfiles: [], localOs: 'linux' });
assert.deepEqual(actual.pluginConnection, original.pluginConnection, 'the real terminal resolver uses the restored opening configuration');
await api.update({ sessions: reconnecting });
assert.equal(await api.find(), undefined, 'host_open must not call that old transport the edited vault connection');
});
}
test('an unchanged restored plugin configuration can be reused after manual reconnect', async (t) => {
const providerId = 'com.example.transport.connection';
const original = host({ protocol: `plugin:${providerId}`, pluginConnection: { providerId, configuration: { endpoint: 'original.invalid' } } });
const restored: TerminalSession = { ...createHostTerminalSession('tab', original), status: 'disconnected', restoreState: 'restored-disconnected' };
const api = await harness({ ...original, label: 'Renamed' }, { sessions: [restored] });
t.after(api.close);
await api.update({ sessions: updateRestoredSessionStatusState([restored], restored.id, 'connecting') });
assert.equal((await api.find())?.sessionId, 'tab');
});
for (const [field, value] of [
['telnetPort', 2324], ['telnetUsername', 'other-telnet-user'], ['telnetPassword', 'rotated-telnet-password'],
] as const) {
test(`a Telnet ${field} edit cannot reuse the old transport`, async (t) => {
const original = host({ protocol: 'telnet', telnetPort: 2323, telnetUsername: 'telnet-user', telnetPassword: 'telnet-password' });
const api = await harness(original);
t.after(api.close);
assert.equal((await api.find())?.sessionId, 'tab');
await api.update({ host: { ...original, [field]: value } });
assert.equal(await api.find(), undefined);
});
}
test('Telnet identity credential rotation and missing identity reject reuse', async (t) => {
const identity: Identity = { id: 'telnet-login', label: 'Telnet login', username: 'telnet-user', password: 'original', authMethod: 'password', created: 0 };
const original = host({ protocol: 'telnet', telnetIdentityId: identity.id });
const api = await harness(original, { identities: [identity] });
t.after(api.close);
assert.equal((await api.find())?.sessionId, 'tab');
for (const identities of [[{ ...identity, username: 'other-user' }], [{ ...identity, password: 'rotated' }], []]) {
await api.update({ identities });
assert.equal(await api.find(), undefined);
}
});
test('Telnet explicit empty login does not fall back to SSH login fields', async (t) => {
const original = host({ protocol: 'telnet', telnetUsername: '', telnetPassword: '', password: 'ssh-only' });
const api = await harness(original);
t.after(api.close);
assert.equal((await api.find())?.sessionId, 'tab');
await api.update({ host: { ...original, label: 'Renamed', username: 'unrelated-ssh-user', password: 'unrelated-ssh-password', identityId: 'missing-ssh-identity', telnetPort: 23 } });
assert.equal((await api.find())?.sessionId, 'tab');
await api.update({ host: { ...original, telnetUsername: undefined, telnetPassword: undefined } });
assert.equal(await api.find(), undefined, 'removing explicit empty overrides enables the actual fallback login');
});
for (const transport of [{ protocol: 'et' as const }, { protocol: 'ssh' as const, etEnabled: true }]) {
test(`changing a jump ET server port rejects the old ${JSON.stringify(transport)} transport`, async (t) => {
const original = host({ ...transport, hostChain: { hostIds: ['jump'] } });
const jump = host({ id: 'jump', hostname: 'jump.invalid', etPort: 2022 });
const api = await harness(original, { hosts: [jump] });
t.after(api.close);
assert.equal((await api.find())?.sessionId, 'tab');
await api.update({ hosts: [original, { ...jump, etPort: 3022 }] });
assert.equal(await api.find(), undefined);
});
}
test('serial direct auto-login password is not masked by an unrelated SSH identity', async (t) => {
const identity: Identity = { id: 'ssh-login', label: 'SSH login', username: 'ssh-user', password: 'ssh-password', authMethod: 'password', created: 0 };
const original = host({ protocol: 'serial', hostname: '/dev/tty.test', username: '', password: 'serial-password', identityId: identity.id });
const api = await harness(original, { identities: [identity] });
t.after(api.close);
assert.equal((await api.find())?.sessionId, 'tab');
await api.update({ host: { ...original, password: 'rotated-serial-password' } });
assert.equal(await api.find(), undefined);
});
test('unchanged serial direct username matches the actual auto-login input', async (t) => {
const original = host({ protocol: 'serial', hostname: '/dev/tty.test', username: 'serial-user', password: 'serial-password' });
const api = await harness(original);
t.after(api.close);
assert.equal((await api.find())?.sessionId, 'tab');
await api.update({ host: { ...original, username: 'other-serial-user' } });
assert.equal(await api.find(), undefined);
});
@@ -0,0 +1,110 @@
import { useCallback, useLayoutEffect, useRef } from 'react';
import type { Host, Identity, SSHKey, TerminalSession } from '../../domain/models';
import { findReusableHostSession } from '../../domain/hostOpenSession';
import { resolveTelnetPassword, resolveTelnetPort, resolveTelnetUsername } from '../../domain/host';
import { isEncryptedCredentialPlaceholder, sanitizeCredentialValue } from '../../domain/credentials';
import { resolveEffectiveTerminalProtocol } from '../../domain/terminalProtocol';
import { buildSftpHostCredentials } from './sftp/useSftpHostCredentials';
import { buildTransferPoolKey } from './sftp/transferConnectionPool';
import { createHostTerminalSession } from './sessionFactories';
type HostOpenSessionReuseInput = {
sessions: TerminalSession[];
hosts: Host[];
keys: SSHKey[];
identities: Identity[];
resolveEffectiveHost: (host: Host) => Host;
};
/** Retain the opening connection identity, never credentials in session storage. */
export function useHostOpenSessionReuse({ sessions, hosts, keys, identities, resolveEffectiveHost }: HostOpenSessionReuseInput) {
const openingKeys = useRef(new Map<string, Promise<string | undefined>>());
const connectionKey = useCallback((host: Host) => {
const protocol = resolveEffectiveTerminalProtocol(host);
if (protocol === 'telnet') {
// Match the actual Telnet starter, including explicit empty overrides.
const identity = host.telnetIdentityId ? identities.find((item) => item.id === host.telnetIdentityId) : undefined;
if (host.telnetIdentityId && (!identity?.username?.trim() || identity.password === undefined)) {
throw new Error('Telnet identity is missing or incomplete');
}
const rawPassword = identity ? identity.password : resolveTelnetPassword(host);
if (isEncryptedCredentialPlaceholder(rawPassword)) throw new Error('Telnet credentials are unavailable');
const options = {
hostname: host.hostname,
port: resolveTelnetPort(host),
username: identity ? identity.username?.trim() : resolveTelnetUsername(host),
password: sanitizeCredentialValue(rawPassword),
};
return buildTransferPoolKey({ ...options, hostId: host.id, protocol, connectionOptions: options });
}
// Reuse the existing credential/chain expansion and transport identity
// comparison. Presentation names and SFTP-only choices do not change a
// terminal's destination or login.
if (protocol === 'serial' && isEncryptedCredentialPlaceholder(host.password)) throw new Error('Serial credentials are unavailable');
const options = buildSftpHostCredentials({
host, hosts: hosts.map(resolveEffectiveHost), keys, identities,
});
delete options.sudo;
delete options.fileProtocol;
for (const hop of options.jumpHosts ?? []) delete hop.label;
const connectionOptions = {
...options,
protocol,
moshEnabled: host.moshEnabled,
moshServerPath: host.moshServerPath,
etEnabled: host.etEnabled,
etPort: host.etPort,
etJumpPorts: protocol === 'et'
? host.hostChain?.hostIds.map((id) => hosts.find((item) => item.id === id)).map((hop) => hop && (resolveEffectiveHost(hop).etPort ?? 2022))
: undefined,
pluginConnection: host.pluginConnection,
serialConfig: protocol === 'serial' ? createHostTerminalSession('', host).serialConfig : undefined,
serialLogin: protocol === 'serial' ? { username: host.username?.trim(), password: sanitizeCredentialValue(host.password) } : undefined,
agentForwarding: host.agentForwarding,
x11Forwarding: host.x11Forwarding,
};
return buildTransferPoolKey({ ...options, protocol: connectionOptions.protocol, connectionOptions });
}, [hosts, keys, identities, resolveEffectiveHost]);
useLayoutEffect(() => {
const liveIds = new Set(sessions.map((session) => session.id));
for (const id of openingKeys.current.keys()) {
if (!liveIds.has(id)) openingKeys.current.delete(id);
}
for (const session of sessions) {
if (openingKeys.current.has(session.id) || session.restoreState) continue;
const host = hosts.find((candidate) => candidate.id === session.hostId);
if (!host) continue;
try {
openingKeys.current.set(session.id, connectionKey({
...resolveEffectiveHost(host),
hostname: session.hostname,
username: session.protocol === 'serial' ? resolveEffectiveHost(host).username : session.username,
port: session.port ?? host.port,
protocol: session.protocol ?? host.protocol,
moshEnabled: session.moshEnabled ?? host.moshEnabled,
etEnabled: session.etEnabled ?? host.etEnabled,
serialConfig: session.serialConfig ?? host.serialConfig,
pluginConnection: session.pluginConnection ?? host.pluginConnection,
}).catch(() => undefined));
} catch {
openingKeys.current.set(session.id, Promise.resolve(undefined));
}
}
}, [sessions, hosts, connectionKey, resolveEffectiveHost]);
return useCallback(async (hostId: string) => {
const host = hosts.find((candidate) => candidate.id === hostId);
if (!host) return undefined;
try {
const requestedKey = await connectionKey(resolveEffectiveHost(host));
const matches = await Promise.all(sessions.map(async (session) => (
session.hostId === hostId && await openingKeys.current.get(session.id) === requestedKey ? session : undefined
)));
return findReusableHostSession(matches.filter((session) => session !== undefined), hostId);
} catch {
// An unresolved/changed credential must never select an old transport.
return undefined;
}
}, [hosts, sessions, connectionKey, resolveEffectiveHost]);
}
+2
View File
@@ -53,6 +53,7 @@ export interface UseVaultAgentBridgeInput {
stopTunnel: VaultAgentApiDeps['stopTunnel'];
stopRuleTunnels: VaultAgentApiDeps['stopRuleTunnels'];
openHost?: VaultAgentApiDeps['openHost'];
findExistingSessionForHost?: VaultAgentApiDeps['findExistingSessionForHost'];
closeSession?: VaultAgentApiDeps['closeSession'];
getScriptSessionMeta?: (sessionId: string) => Pick<TerminalSession, 'status' | 'customName' | 'hostLabel' | 'hostname' | 'username'> | undefined;
}
@@ -271,6 +272,7 @@ export function useVaultAgentBridge(input: UseVaultAgentBridgeInput): void {
openHost: current.openHost
? (host, isExternalMcpCall) => current.openHost!(host, isExternalMcpCall)
: undefined,
findExistingSessionForHost: current.findExistingSessionForHost,
closeSession: current.closeSession
? (sessionId) => current.closeSession!(sessionId)
: undefined,
+31
View File
@@ -0,0 +1,31 @@
import assert from 'node:assert/strict';
import { test } from 'node:test';
import type { TerminalSession } from './models';
import { findReusableHostSession } from './hostOpenSession';
function tab(id: string, status: TerminalSession['status'], extra: Partial<TerminalSession> = {}): TerminalSession {
return { id, hostId: 'host-1', hostLabel: 'Host', hostname: 'example.invalid', username: 'user', status, ...extra };
}
test('host reuse prefers a connected tab over a connecting tab for the same host', () => {
assert.deepEqual(findReusableHostSession([
tab('other-host', 'connected', { hostId: 'host-2' }),
tab('retrying', 'connecting'),
tab('live', 'connected'),
], 'host-1'), { sessionId: 'live', connected: true });
});
test('host reuse keeps the same tab during the reconnect delay and connection attempt', () => {
assert.deepEqual(findReusableHostSession([tab('retrying', 'connecting')], 'host-1'), {
sessionId: 'retrying', connected: false,
});
});
test('host reuse excludes restored placeholders and inactive disconnected tabs', () => {
assert.equal(findReusableHostSession([
tab('manual-disconnect', 'disconnected'),
tab('restored', 'disconnected', { restoreState: 'restored-disconnected' }),
tab('restoring', 'connecting', { restoreState: 'restored-disconnected' }),
], 'host-1'), undefined);
assert.equal(findReusableHostSession([tab('live', 'connected')], ''), undefined);
});
+14
View File
@@ -0,0 +1,14 @@
import type { TerminalSession } from './models';
export function findReusableHostSession(sessions: TerminalSession[], hostId: string) {
if (!hostId) return undefined;
// Auto-reconnect publishes connecting even during the retry delay. A plain
// disconnected/restored tab needs user action and cannot satisfy host_open.
const matches = sessions.filter((session) => (
session.hostId === hostId
&& session.restoreState !== 'restored-disconnected'
&& (session.status === 'connected' || session.status === 'connecting')
));
const picked = matches.find((session) => session.status === 'connected') ?? matches[0];
return picked ? { sessionId: picked.id, connected: picked.status === 'connected' } : undefined;
}
+27 -6
View File
@@ -1615,6 +1615,17 @@ const dispatchCapabilityRpc = createCapabilityRpcDispatcher({
// 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;
// Reuse adds visibility only: never grant ownership or change the original
// owner's idle lifecycle merely because another chat referenced the tab.
if (result?.reused === true) {
if (generation?.ownershipGeneration?.revoked) {
// A late renderer merge must not recreate a deleted chat's scope.
scopedMetadata.delete(chatSessionId);
return;
}
ensureOpenedSessionMetadata(chatSessionId, sessionId, result);
return;
}
if (!openedSessionOwnership.register(
chatSessionId,
sessionId,
@@ -2232,10 +2243,16 @@ async function handleGetContext(params) {
const hasCommandablePty = ptyStream && typeof ptyStream.write === "function";
const hasSshExec = sshClient && typeof sshClient.exec === "function";
const hasSerialPort = session.serialPort && typeof session.serialPort.write === "function";
if (!hasCommandablePty && !hasSshExec && !hasSerialPort) continue;
// Look up metadata scoped to this chat session
const meta = getSessionMeta(sessionId, chatSessionId) || {};
const hasTransport = hasCommandablePty || hasSshExec || hasSerialPort;
// Look up metadata scoped to this chat session.
const scopeMeta = getSessionMeta(sessionId, chatSessionId);
// Disconnected/reconnecting tabs keep their session entry (SSH
// auto-reconnect reuses the sessionId), so keep them visible with
// connected:false instead of hiding them — otherwise the agent cannot
// see or wait for a tab it opened and reopens it as a duplicate.
// Without scoped metadata the session is not in this agent's scope.
if (!hasTransport && !scopeMeta) continue;
const meta = scopeMeta || {};
hosts.push({
sessionId,
hostname: meta.hostname || session.hostname || "",
@@ -2245,7 +2262,7 @@ async function handleGetContext(params) {
protocol: meta.protocol || session.protocol || session.type || "",
shellType: meta.shellType || session.shellKind || "",
deviceType: meta.deviceType || "",
connected: meta.connected !== undefined ? meta.connected : !!(session.sshClient || session.conn || ptyStream || session.serialPort),
connected: meta.connected !== undefined ? meta.connected : hasTransport,
hostId: meta.hostId || "",
hostChain: meta.hostChain || [],
activePortForwards: meta.activePortForwards || [],
@@ -2257,7 +2274,10 @@ async function handleGetContext(params) {
for (const sessionId of resolvedScopedIds) {
if (addedHostIds.has(sessionId)) continue;
const meta = getSessionMeta(sessionId, chatSessionId);
if (!meta || meta.connected === false) continue;
if (!meta) continue;
// Keep disconnected/reconnecting sessions listed with connected:false so
// the agent can wait on the existing sessionId instead of reopening the
// host as a duplicate tab (SSH auto-reconnect preserves the sessionId).
hosts.push(buildHostFromMetadata(sessionId, meta));
addedHostIds.add(sessionId);
}
@@ -2278,6 +2298,7 @@ async function handleGetContext(params) {
"Use the provided tools to execute commands through the sessions exposed by Netcatty. " +
buildTerminalToolGuidance(toolHints) +
"Serial sessions (protocol: serial, shellType: raw) do not run a standard shell — commands are sent as-is. " +
"A session listed with connected false is still in the scope. SSH auto-reconnect keeps the same sessionId: wait and retry that session while it reconnects. Inactive disconnected or restored tabs may require user action; host_open reuses connected or connecting sessions by default and opens a connection when none is usable. " +
"Network device sessions (deviceType: network) use vendor CLIs (Huawei VRP, Cisco IOS, etc.) — commands are sent as-is without shell wrapping, and exit codes are unavailable. " +
"Vault snippets, port forwarding rules/tunnels, and SFTP read/write tools are available when exposed in the tool list. " +
"Always prefer these tools over suggesting the user to do things manually.",
@@ -0,0 +1,144 @@
"use strict";
const assert = require("node:assert/strict");
const test = require("node:test");
const { isolateCliDiscoveryFile } = require("./cliDiscoveryTestIsolation.cjs");
isolateCliDiscoveryFile();
function loadFreshBridge() {
const bridgePath = require.resolve("./mcpServerBridge.cjs");
delete require.cache[bridgePath];
return require("./mcpServerBridge.cjs");
}
function setup(t, { sessions } = {}) {
const bridge = loadFreshBridge();
t.after(() => bridge.cleanup());
bridge.init({ sessions: sessions || new Map(), electronModule: null });
bridge.setPermissionMode("auto");
return bridge;
}
test('reusing a user tab adds visibility without ownership or idle tracking', async (t) => {
const bridge = setup(t);
bridge.setVaultAgentInvoker(async () => ({ ok: true, reused: true, sessionId: 'user-tab', hostId: 'host-b', status: 'connected' }));
await bridge.dispatchBuiltinRpc('public/vault/hosts/open', { chatSessionId: 'chat-b', hostId: 'host-b' });
assert.deepEqual(bridge.getScopedSessionIds('chat-b'), ['user-tab']);
assert.equal(bridge.reportOpenedSessionActivity({ sessionId: 'user-tab', phase: 'touch' }), false);
const close = await bridge.dispatchBuiltinRpc('public/session/close', { chatSessionId: 'chat-b', sessionId: 'user-tab' });
assert.equal(close.ok, false);
assert.match(close.error, /not opened by the current AI scope/);
});
test('reuse keeps the original chat owner and its idle tracking', async (t) => {
const bridge = setup(t);
const calls = [];
bridge.setVaultAgentInvoker(async (op, params) => {
calls.push({ op, params });
if (op === 'session.close') return { ok: true };
return { ok: true, reused: params.chatSessionId !== 'owner', sessionId: 'shared-tab', hostId: 'host-b', status: 'connected' };
});
for (const chatSessionId of ['owner', 'borrower']) {
await bridge.dispatchBuiltinRpc('public/vault/hosts/open', { chatSessionId, hostId: 'host-b' });
}
assert.equal(bridge.reportOpenedSessionActivity({ sessionId: 'shared-tab', phase: 'touch' }), true);
assert.equal((await bridge.dispatchBuiltinRpc('public/session/close', { chatSessionId: 'borrower', sessionId: 'shared-tab' })).ok, false);
assert.equal((await bridge.dispatchBuiltinRpc('public/session/close', { chatSessionId: 'owner', sessionId: 'shared-tab' })).ok, true);
assert.equal(calls.filter(({ op }) => op === 'session.close').length, 1);
});
test('a deleted chat cannot reclaim a reused tab or revive its own scope', async (t) => {
const bridge = setup(t);
let finishOpen;
const calls = [];
bridge.updateSessionMetadata([{ sessionId: 'user-tab', connected: true }], 'original');
bridge.setVaultAgentInvoker((op) => {
calls.push(op);
return new Promise((resolve) => { finishOpen = resolve; });
});
const opening = bridge.dispatchBuiltinRpc('public/vault/hosts/open', { chatSessionId: 'deleted', hostId: 'host-b' });
await bridge.cleanupScopedMetadata('deleted');
bridge.mergeSessionMetadata([{ sessionId: 'user-tab', connected: true }], 'deleted');
finishOpen({ ok: true, reused: true, sessionId: 'user-tab', status: 'connected' });
assert.equal((await opening).ok, true);
assert.deepEqual(calls, ['host.open']);
assert.deepEqual(bridge.getScopedSessionIds('deleted'), []);
assert.deepEqual(bridge.getScopedSessionIds('original'), ['user-tab']);
assert.equal(bridge.reportOpenedSessionActivity({ sessionId: 'user-tab', phase: 'touch' }), false);
});
test("getContext keeps a scoped disconnected session visible with connected:false", async (t) => {
const bridge = setup(t);
bridge.updateSessionMetadata([
{
sessionId: "sess-reconnecting",
hostname: "10.0.0.2",
label: "server-b",
connected: true,
hostId: "host-b",
protocol: "ssh",
},
], "chat-1");
// Connection dropped: renderer pushes connected:false while the tab
// auto-reconnects. The scope keeps the same sessionId.
bridge.updateSessionMetadata([
{
sessionId: "sess-reconnecting",
hostname: "10.0.0.2",
label: "server-b",
connected: false,
hostId: "host-b",
protocol: "ssh",
},
], "chat-1");
const environment = await bridge.dispatchBuiltinRpc("netcatty/getContext", {
chatSessionId: "chat-1",
});
const host = environment.hosts.find((entry) => entry.sessionId === "sess-reconnecting");
assert.ok(host, "reconnecting session must stay visible to the agent");
assert.equal(host.connected, false);
});
test("getContext keeps a live-map session without transport visible when scoped", async (t) => {
const bridge = setup(t, {
sessions: new Map([["sess-live", { hostname: "10.0.0.3", label: "server-c" }]]),
});
bridge.updateSessionMetadata([
{
sessionId: "sess-live",
hostname: "10.0.0.3",
label: "server-c",
connected: false,
hostId: "host-c",
protocol: "ssh",
},
], "chat-1");
const environment = await bridge.dispatchBuiltinRpc("netcatty/getContext", {
chatSessionId: "chat-1",
});
const host = environment.hosts.find((entry) => entry.sessionId === "sess-live");
assert.ok(host, "reconnecting live-map session must stay visible");
assert.equal(host.connected, false);
});
test("getContext still hides disconnected sessions outside the agent scope", async (t) => {
const bridge = setup(t);
bridge.updateSessionMetadata([
{
sessionId: "foreign",
hostname: "10.0.0.9",
label: "foreign",
connected: false,
hostId: "host-x",
protocol: "ssh",
},
], "chat-other");
const environment = await bridge.dispatchBuiltinRpc("netcatty/getContext", {
chatSessionId: "chat-1",
});
assert.equal(environment.hosts.length, 0);
});
+1 -1
View File
@@ -48,7 +48,7 @@ const VAULT_CAPABILITIES = [
domain: "vault",
status: CAPABILITY_STATUS.IMPLEMENTED,
description:
"Open a vault host by creating a new terminal tab and starting the connection. Returns the new sessionId so you can run terminal/SFTP tools against it. Use vault_hosts_list first when you only know the label or hostname.",
"Open a vault host by reusing its existing session when its connection settings still match (connected or connecting, including auto-reconnecting — same sessionId, no duplicate tab). Only opens a new terminal tab when no usable session for this host exists, or when newTab is true. Inactive disconnected and restored tabs are not reused. Returns the sessionId so you can run terminal/SFTP tools against it. Use vault_hosts_list first when you only know the label or hostname.",
// Sidebar Catty is scoped to already-open terminals/workspaces and must not
// expand that scope mid-turn. Keep host_open for MCP / CLI / global agent.
agentKinds: [AGENT_KINDS.GLOBAL],
+2 -1
View File
@@ -83,6 +83,7 @@ const TOOL_INPUT_FIELDS = Object.freeze({
"vault.host.list": {},
"vault.host.open": {
hostId: { type: "string", description: "Vault host ID to open. Use vault_hosts_list / host_get to resolve id from label or hostname." },
newTab: { type: "string", optional: true, description: "true or false. Force a brand-new tab even when a session for this host already exists. Default false — reuse the existing session." },
},
"vault.hosts.create": {
hosts: {
@@ -420,7 +421,7 @@ const MODEL_DESCRIPTION_HINTS = Object.freeze({
"vault.host.notes.set":
"Host metadata notes on a saved host — not Vault → Notes sidebar entries. Prefer vault_notes_create/update when the user wants vault notes they can open in the Notes sidebar.",
"vault.host.open":
"Opens a terminal tab for a saved vault host (same as clicking the host in Netcatty). Connection may still be establishing when the tool returns — use get_environment or wait briefly before terminal_execute if needed. Call session_close with the returned sessionId when the task is finished. Auth prompts (passphrase / keyboard-interactive) still require the user in the Netcatty UI.",
"Reuse-first: if this hostId already has a connected or connecting session with matching connection settings (including one auto-reconnecting after a drop, which keeps the same sessionId), that sessionId is returned and no new tab is opened — retry terminal_execute against it rather than reopening the host. Open a new tab only when get_environment shows no session for the host, when the task truly needs a parallel connection, or with newTab true. Call session_close when finished only for a newly opened session (reused is not true). A reused session keeps its original owner; do not close it as task cleanup. Disconnected or restored tabs needing user action are not reused. Connection may still be establishing when the tool returns — use get_environment or wait briefly before terminal_execute if needed. Auth prompts (passphrase / keyboard-interactive) still require the user in the Netcatty UI.",
"vault.host.list":
"Use when the user names a saved Netcatty host but get_environment has no matching live session. Resolve the hostId here, call host_open, then call get_environment to obtain the new sessionId.",
"vault.host.import":
@@ -47,6 +47,7 @@ function createVaultService(ctx = {}) {
return invokeVaultAgent("host.open", {
hostId: params.hostId,
chatSessionId: params.chatSessionId,
newTab: params.newTab,
});
},
createHosts: async (params = {}) => {
@@ -36,10 +36,11 @@ test("vault service delegates host open to vault agent bridge", async () => {
return { ok: true, sessionId: "sess-1", hostId: params.hostId, status: "connecting" };
},
});
const result = await service.openHost({ hostId: "host-1", chatSessionId: "chat-1" });
const result = await service.openHost({ hostId: "host-1", chatSessionId: "chat-1", newTab: "true" });
assert.equal(invokedOp, "host.open");
assert.equal(invokedParams.hostId, "host-1");
assert.equal(invokedParams.chatSessionId, "chat-1");
assert.equal(invokedParams.newTab, "true");
assert.equal(result.ok, true);
assert.equal(result.sessionId, "sess-1");
});
@@ -585,12 +585,17 @@
"toolName": "host_open",
"rpcMethod": "vault/hosts/open",
"localExecution": false,
"description": "Open a vault host by creating a new terminal tab and starting the connection. Returns the new sessionId so you can run terminal/SFTP tools against it. Use vault_hosts_list first when you only know the label or hostname. Opens a terminal tab for a saved vault host (same as clicking the host in Netcatty). Connection may still be establishing when the tool returns — use get_environment or wait briefly before terminal_execute if needed. Call session_close with the returned sessionId when the task is finished. Auth prompts (passphrase / keyboard-interactive) still require the user in the Netcatty UI.",
"description": "Open a vault host by reusing its existing session when its connection settings still match (connected or connecting, including auto-reconnecting — same sessionId, no duplicate tab). Only opens a new terminal tab when no usable session for this host exists, or when newTab is true. Inactive disconnected and restored tabs are not reused. Returns the sessionId so you can run terminal/SFTP tools against it. Use vault_hosts_list first when you only know the label or hostname. Reuse-first: if this hostId already has a connected or connecting session with matching connection settings (including one auto-reconnecting after a drop, which keeps the same sessionId), that sessionId is returned and no new tab is opened — retry terminal_execute against it rather than reopening the host. Open a new tab only when get_environment shows no session for the host, when the task truly needs a parallel connection, or with newTab true. Call session_close when finished only for a newly opened session (reused is not true). A reused session keeps its original owner; do not close it as task cleanup. Disconnected or restored tabs needing user action are not reused. Connection may still be establishing when the tool returns — use get_environment or wait briefly before terminal_execute if needed. Auth prompts (passphrase / keyboard-interactive) still require the user in the Netcatty UI.",
"inputShape": {
"hostId": {
"type": "string",
"optional": false,
"description": "Vault host ID to open. Use vault_hosts_list / host_get to resolve id from label or hostname."
},
"newTab": {
"type": "string",
"optional": true,
"description": "true or false. Force a brand-new tab even when a session for this host already exists. Default false — reuse the existing session."
}
},
"policy": {
@@ -0,0 +1,132 @@
import assert from 'node:assert/strict';
import { createRequire } from 'node:module';
import { test } from 'node:test';
import type { Host, TerminalSession } from '../../domain/models';
import React from 'react';
import { act, create, type ReactTestRenderer } from 'react-test-renderer';
import { updateRestoredSessionStatusState } from '../../application/state/sessionRestoreState';
import { createHostTerminalSession } from '../../application/state/sessionFactories';
import { useHostOpenSessionReuse } from '../../application/state/useHostOpenSessionReuse';
import { handleVaultAgentOp, type VaultAgentApiDeps } from './vaultAgentBridgeClient';
const require = createRequire(import.meta.url);
const { isolateCliDiscoveryFile } = require('../../electron/bridges/cliDiscoveryTestIsolation.cjs');
isolateCliDiscoveryFile();
const bridge = require('../../electron/bridges/mcpServerBridge.cjs');
const { registerMcpTools } = require('../../electron/capabilities/codegen/mcpToolRegistry.cjs');
test('MCP host_open forwards the schema input through RPC and vault service to the real renderer handler', async (t) => {
bridge.init({ sessions: new Map(), electronModule: null });
bridge.setPermissionMode('auto');
t.after(() => bridge.cleanup());
const host: Host = { id: 'host-1', label: 'Host', hostname: 'example.invalid', username: 'user', protocol: 'ssh', os: 'linux', tags: [] };
const tabs: TerminalSession[] = [{ id: 'existing', hostId: host.id, hostLabel: host.label, hostname: host.hostname, username: host.username, status: 'connecting' }];
let finder: ReturnType<typeof useHostOpenSessionReuse>;
let renderer: ReactTestRenderer;
function Harness() {
finder = useHostOpenSessionReuse({ sessions: [...tabs], hosts: [{ ...host }], keys: [], identities: [], resolveEffectiveHost: (value) => value });
return null;
}
const render = async () => act(async () => {
if (renderer) renderer.update(React.createElement(Harness));
else renderer = create(React.createElement(Harness));
});
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
t.after(async () => act(async () => renderer.unmount()));
await render();
const deps = {
getHosts: () => [host],
resolveEffectiveHost: (value: Host) => value,
findExistingSessionForHost: (hostId: string) => finder(hostId),
openHost: (openedHost: Host) => {
const sessionId = `new-${tabs.length}`;
tabs.push(createHostTerminalSession(sessionId, openedHost));
return { ok: true as const, sessionId, host };
},
} as unknown as VaultAgentApiDeps;
bridge.setVaultAgentInvoker((op: string, params: Record<string, unknown>) => handleVaultAgentOp(op, params, deps));
let openTool: ((args: Record<string, unknown>) => Promise<unknown>) | undefined;
let schema: { parse: (args: Record<string, unknown>) => Record<string, unknown> };
registerMcpTools({
tool(name: string, _description: string, shape: Record<string, unknown>, handler: typeof openTool) {
if (name !== 'host_open') return;
schema = require('zod').z.object(shape);
openTool = handler;
},
}, {
rpcCall: (method: string, params: Record<string, unknown>) => bridge.dispatchBuiltinRpc(method, params),
scopeParams: { chatSessionId: 'integration-chat' },
guardWriteOperation: () => null,
catalogDescription: (_name: string, fallback: string) => fallback,
});
assert.ok(openTool);
const run = async (args: Record<string, unknown>) => {
await render();
const response = await openTool!(schema.parse(args)) as { content: { text: string }[] };
await render();
return JSON.parse(response.content[0].text);
};
for (let index = 0; index < 3; index++) {
const reused = await run({ hostId: host.id });
assert.equal(reused.sessionId, 'existing');
assert.equal(reused.reused, true);
}
assert.equal(tabs.length, 1);
const fresh = await run({ hostId: host.id, newTab: 'true' });
assert.equal(fresh.sessionId, 'new-1');
assert.equal(tabs.length, 2);
// An inactive tab requires a real open instead of an endless wait.
for (const tab of tabs) tab.status = 'disconnected';
const recovered = await run({ hostId: host.id, newTab: 'false' });
assert.equal(recovered.sessionId, 'new-2');
assert.equal(tabs.length, 3);
tabs[2].status = 'connected';
host.label = 'Renamed only';
assert.equal((await run({ hostId: host.id })).sessionId, recovered.sessionId);
host.hostname = 'replacement.invalid';
const editedTarget = await run({ hostId: host.id });
assert.notEqual(editedTarget.sessionId, recovered.sessionId, 'a saved endpoint edit must not reuse the old transport');
assert.equal(editedTarget.reused, undefined);
tabs.length = 0;
host.protocol = 'telnet';
host.telnetPort = 2323;
host.telnetUsername = 'telnet-user';
host.telnetPassword = 'telnet-password';
const telnet = await run({ hostId: host.id });
tabs[0].status = 'connected';
assert.equal((await run({ hostId: host.id })).sessionId, telnet.sessionId);
host.telnetPort = 2324;
const changedTelnet = await run({ hostId: host.id });
assert.notEqual(changedTelnet.sessionId, telnet.sessionId, 'the actual Telnet destination port change requires a new transport');
assert.equal(changedTelnet.reused, undefined);
// Saved serial hosts use the same first-party host_open path and factory.
tabs.length = 0;
host.protocol = 'serial';
host.hostname = '/dev/tty.test';
host.username = '';
host.serialConfig = { path: host.hostname, baudRate: 9600, dataBits: 8, parity: 'none', flowControl: 'none' };
const serial = await run({ hostId: host.id });
assert.equal(serial.protocol, 'serial');
assert.equal(tabs[0].serialConfig?.baudRate, 9600);
tabs[0].status = 'connected';
assert.equal((await run({ hostId: host.id })).sessionId, serial.sessionId);
host.serialConfig = { ...host.serialConfig, baudRate: 19200 };
const changedSerial = await run({ hostId: host.id });
assert.notEqual(changedSerial.sessionId, serial.sessionId, 'an edited serial configuration must not reuse the old parameters');
assert.equal(changedSerial.reused, undefined);
assert.equal(tabs[1].serialConfig?.baudRate, 19200);
assert.equal(tabs[0].serialConfig?.baudRate, 9600);
tabs.length = 0;
host.protocol = 'plugin:com.example.transport.connection';
host.hostname = 'plugin-host';
host.pluginConnection = { providerId: 'com.example.transport.connection', configuration: { endpoint: 'original.invalid' } };
tabs.push({ ...createHostTerminalSession('restored-plugin', host), status: 'disconnected', restoreState: 'restored-disconnected' });
await render();
host.pluginConnection = { ...host.pluginConnection, configuration: { endpoint: 'replacement.invalid' } };
tabs.splice(0, tabs.length, ...updateRestoredSessionStatusState(tabs, 'restored-plugin', 'connecting'));
const changedPlugin = await run({ hostId: host.id });
assert.notEqual(changedPlugin.sessionId, 'restored-plugin', 'restored plugin transport must not mask the edited provider configuration');
assert.equal(changedPlugin.reused, undefined);
assert.deepEqual(tabs[0].pluginConnection?.configuration, { endpoint: 'original.invalid' });
assert.deepEqual(tabs[1].pluginConnection?.configuration, { endpoint: 'replacement.invalid' });
});
@@ -496,6 +496,110 @@ describe('handleVaultAgentOp vault hosts', () => {
]);
});
it('host.open reuses an existing session for the same host instead of opening a new tab', async (t) => {
const { netcattyBridge } = await import('../services/netcattyBridge');
const merged: Array<{ sessions: Array<{ sessionId: string; connected?: boolean }>; scope: string }> = [];
t.mock.method(netcattyBridge, 'get', () => ({
aiMcpMergeSessions: async (
sessions: Array<{ sessionId: string; connected?: boolean }>,
scope: string,
) => {
merged.push({ sessions, scope });
},
} as unknown as NetcattyBridge));
const host: Host = {
id: 'host-reuse-1',
label: 'edge',
hostname: 'edge.example.com',
username: 'ops',
port: 22,
};
let openHostCalls = 0;
const deps = createDeps({
hosts: [host],
openHost: (hostToOpen: Host) => {
openHostCalls += 1;
return { ok: true, sessionId: `session-${hostToOpen.id}`, host: hostToOpen };
},
findExistingSessionForHost: (hostId: string) => hostId === host.id
? { sessionId: 'session-existing-1', connected: false }
: undefined,
});
const result = await handleVaultAgentOp(
'host.open',
{ hostId: host.id, chatSessionId: '__external_mcp__' },
deps,
);
assert.equal(result.ok, true);
assert.equal((result as { sessionId?: string }).sessionId, 'session-existing-1');
assert.equal((result as { reused?: boolean }).reused, true);
assert.equal((result as { status?: string }).status, 'reconnecting');
assert.equal(openHostCalls, 0, 'must not open a duplicate tab');
// The reused session is re-registered in the MCP scope with its live
// reconnecting state so terminal_execute can target it immediately.
assert.deepEqual(merged.map(({ sessions, scope }) => ({ scope, connected: sessions[0]?.connected })), [
{ scope: '__external_mcp__', connected: false },
]);
});
it('host.open reuse reports a connected session as connected', async () => {
const host: Host = { id: 'host-reuse-2', label: 'edge', hostname: 'edge.example.com' };
let openHostCalls = 0;
const deps = createDeps({
hosts: [host],
openHost: (hostToOpen: Host) => {
openHostCalls += 1;
return { ok: true, sessionId: `session-${hostToOpen.id}`, host: hostToOpen };
},
findExistingSessionForHost: (hostId: string) => hostId === host.id
? { sessionId: 'session-existing-2', connected: true }
: undefined,
});
const result = await handleVaultAgentOp('host.open', { hostId: host.id }, deps);
assert.equal(result.ok, true);
assert.equal((result as { sessionId?: string }).sessionId, 'session-existing-2');
assert.equal((result as { status?: string }).status, 'connected');
assert.equal((result as { connected?: boolean }).connected, true);
assert.equal(openHostCalls, 0);
});
it('host.open newTab forces a fresh session even when one exists', async () => {
const host: Host = { id: 'host-reuse-3', label: 'edge', hostname: 'edge.example.com' };
const deps = createDeps({
hosts: [host],
findExistingSessionForHost: () => ({ sessionId: 'session-existing-3', connected: true }),
});
const result = await handleVaultAgentOp(
'host.open',
{ hostId: host.id, newTab: 'true' },
deps,
);
assert.equal(result.ok, true);
assert.equal((result as { sessionId?: string }).sessionId, 'session-host-reuse-3');
assert.equal((result as { status?: string }).status, 'connecting');
});
it('host.open still opens a new tab when no session exists for the host', async () => {
const host: Host = { id: 'host-reuse-4', label: 'edge', hostname: 'edge.example.com' };
const deps = createDeps({
hosts: [host],
findExistingSessionForHost: () => undefined,
});
const result = await handleVaultAgentOp('host.open', { hostId: host.id }, deps);
assert.equal(result.ok, true);
assert.equal((result as { sessionId?: string }).sessionId, 'session-host-reuse-4');
assert.equal((result as { reused?: boolean }).reused, undefined);
assert.equal((result as { status?: string }).status, 'connecting');
});
it('host.open does not treat a missing chatSessionId as an external MCP call', async () => {
const host: Host = {
id: 'host-open-4',
+54 -10
View File
@@ -556,6 +556,15 @@ export interface VaultAgentApiDeps {
ok: false;
error: string;
};
/**
* Find an existing terminal session for a vault host so `host.open` can
* reuse it instead of always opening a new tab. Reconnecting SSH tabs keep
* the same sessionId. Check the opening connection identity before reuse.
*/
findExistingSessionForHost?: (hostId: string) => {
sessionId: string;
connected: boolean;
} | undefined | Promise<{ sessionId: string; connected: boolean } | undefined>;
closeSession?: (sessionId: string) => {
ok: true;
} | {
@@ -584,6 +593,7 @@ async function registerOpenedSessionInMcpScope(
sessionId: string,
host: Host,
chatSessionId?: string,
connected = false,
): Promise<void> {
let bridge: ReturnType<typeof netcattyBridge.get> | undefined;
try {
@@ -612,7 +622,7 @@ async function registerOpenedSessionInMcpScope(
username: host.username || '',
protocol,
deviceType: host.deviceType || '',
connected: false,
connected,
hostChain: [],
activePortForwards: [],
};
@@ -671,9 +681,6 @@ export async function handleVaultAgentOp(
if (!hostId) return { ok: false, error: 'hostId is required.' };
const host = deps.getHosts().find((entry) => entry.id === hostId);
if (!host) return { ok: false, error: `Host "${hostId}" was not found.` };
if (typeof deps.openHost !== 'function') {
return { ok: false, error: 'Host open is not available in this window.' };
}
const chatSessionId = typeof params.chatSessionId === 'string'
? params.chatSessionId
@@ -683,12 +690,6 @@ export async function handleVaultAgentOp(
// not a reliable signal — compare against the constant instead.
const isExternalMcpCall = chatSessionId === EXTERNAL_MCP_CHAT_SESSION_ID;
const effectiveHost = deps.resolveEffectiveHost(host);
const opened = deps.openHost(effectiveHost, isExternalMcpCall);
if (!opened.ok) {
return { ok: false, error: opened.error };
}
await registerOpenedSessionInMcpScope(opened.sessionId, effectiveHost, chatSessionId);
const protocol = effectiveHost.etEnabled
? 'et'
@@ -696,6 +697,49 @@ export async function handleVaultAgentOp(
? 'mosh'
: (effectiveHost.protocol || 'ssh');
// Reuse-first: a terminal tab keeps its sessionId across SSH
// auto-reconnect, so blindly opening a new tab stacks duplicate tabs
// (and steals focus) after every network blip. Reuse the live or
// reconnecting session for this host unless the caller forces a new tab.
const wantsNewTab = parseOptionalBoolean(params.newTab) ?? false;
const existing = !wantsNewTab
? await deps.findExistingSessionForHost?.(effectiveHost.id)
: undefined;
if (existing) {
await registerOpenedSessionInMcpScope(
existing.sessionId,
effectiveHost,
chatSessionId,
existing.connected,
);
return {
ok: true,
sessionId: existing.sessionId,
hostId: effectiveHost.id,
...(isSavedVaultHost(effectiveHost) && protocol !== 'serial' && protocol !== 'local'
? { savedHostId: effectiveHost.id }
: {}),
reused: true,
connected: existing.connected,
status: existing.connected ? 'connected' : 'reconnecting',
protocol,
host: summarizeHostForList(effectiveHost),
message:
'Reused the existing session for this host (same sessionId). If it is not connected yet, the tab is connecting or reconnecting — wait briefly and use get_environment or terminal_execute against this sessionId. Complete any authentication prompt in the Netcatty UI if needed. Do not call session_close on it unless the user asks; pass newTab true to force a separate tab instead.',
};
}
if (typeof deps.openHost !== 'function') {
return { ok: false, error: 'Host open is not available in this window.' };
}
const opened = deps.openHost(effectiveHost, isExternalMcpCall);
if (!opened.ok) {
return { ok: false, error: opened.error };
}
await registerOpenedSessionInMcpScope(opened.sessionId, effectiveHost, chatSessionId);
return {
ok: true,
sessionId: opened.sessionId,