Compare commits

...
Author SHA1 Message Date
bincxz c557df5e6b fix: respect Telnet proxy rejection before reusing sessions 2026-10-15 12:36:17 +08:00
bincxz 332d000dec fix: type local transport comparison with empty login 2026-10-15 12:26:31 +08:00
bincxz ac7c8449d6 fix: retain reconnecting context and local opening identity 2026-10-15 12:25:11 +08:00
bincxz c70806895b fix: compare resolved terminal protocol endpoints and login 2026-10-15 12:22:25 +08:00
Netcatty-Bot b4e056dd4a fix(sftp): support writable GVFS destinations without losing file protection
Handle unsupported copyFile operations on writable GVFS/FUSE destinations using the existing exclusive bounded copy path. New files accept the mount's creation permissions; replacing an existing file still requires equivalent protection, and failures retain recoverable data.

Only explicitly optional permission/timestamp updates may tolerate permission refusal. Content writes, close errors, required protection, and rollback preservation remain strict. Current-head review and required checks were verified before merging.

Related to #3611. Keep the report open pending delivery and actual environment confirmation.
2026-10-15 12:19:58 +08:00
d5c7d3fbf4 fix(ai): make host_open reuse-first and keep reconnecting sessions in get_environment (#3614)
* fix(#3613): automated AI fix

* fix(ai): preserve owners when reusing active host sessions

* fix(ai): match opening connection identity before host tab reuse

* fix(ai): preserve serial settings when matching reusable tabs

* fix: capture restored plugin connection before host reuse

---------

Co-authored-by: netcatty-bot <308658023+netcatty-bot@users.noreply.github.com>
Co-authored-by: bincxz <16399091+binaricat@users.noreply.github.com>
2026-10-15 12:17:34 +08:00
陈大猫 1ab07ffee4 fix(ssh): preserve password fallback after unavailable agent
Allow ssh2 to continue to another authentication method when a system SSH agent is unavailable, for both target and jump-host connections. Keep the authentication deadline and fatal-error handling intact.

Validated with real SSH servers and Unix agent sockets through normal terminal startup, including forwarding, successful password fallback, real authentication failures, and timeouts. 118 focused checks and lint passed. Current-head review and required CI were checked before merging.

Related to #3640. Keep the report open for the reporter's environment and delivery validation.
2026-10-15 12:13:43 +08:00
23 changed files with 1534 additions and 70 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,274 @@
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 { GroupConfig, Host, Identity, SSHKey, TerminalSession } from '../../domain/models';
import { updateRestoredSessionStatusState } from './sessionRestoreState';
import { resolveEffectiveTerminalHost, 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[]; resolveEffectiveHost?: (host: Host) => Host } = {}) {
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: extra.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);
});
for (const [field, value] of [
['localShell', '/bin/bash'], ['localShellArgs', ['--norc']], ['localStartDir', '/replacement'],
] as const) {
test(`a saved local ${field} edit cannot reuse the old process`, async (t) => {
const original = host({ protocol: 'local', hostname: 'localhost', localShell: '/bin/zsh', localShellArgs: ['-l'], localStartDir: '/original' });
const session = { ...createHostTerminalSession('tab', original), status: 'connected', localShell: original.localShell, localShellArgs: original.localShellArgs, localStartDir: original.localStartDir } as TerminalSession;
const api = await harness(original, { sessions: [session] });
t.after(api.close);
assert.equal((await api.find())?.sessionId, 'tab');
await api.update({ host: { ...original, [field]: value } });
assert.equal(await api.find(), undefined);
const alreadyEdited = await harness({ ...original, [field]: value }, { sessions: [session] });
t.after(alreadyEdited.close);
assert.equal(await alreadyEdited.find(), undefined, 'opening fields come from the actual local session');
});
}
test('local default shell and cwd treat empty values as omitted but explicit empty args stay distinct', async (t) => {
const original = host({ protocol: 'local', hostname: 'localhost' });
const api = await harness(original);
t.after(api.close);
await api.update({ host: { ...original, localShell: '', localStartDir: '', label: 'Renamed' } });
assert.equal((await api.find())?.sessionId, 'tab');
await api.update({ host: { ...original, localShellArgs: [] } });
assert.equal(await api.find(), undefined, 'empty args bypass backend login-shell defaults');
});
for (const [label, proxy] of [
['a missing saved profile', { proxyProfileId: 'missing-profile' }],
['a usable proxy', { proxyConfig: { type: 'socks5', host: 'proxy.invalid', port: 1080 } }],
['a missing proxy identity', { proxyConfig: { type: 'socks5', host: '', port: 0, identityId: 'missing-identity' } }],
['an incomplete proxy identity', { proxyConfig: { type: 'socks5', host: '', port: 0, identityId: 'incomplete-identity' } }],
] as const) {
test(`Telnet adding ${label} cannot bypass actual starter rejection through reuse`, async (t) => {
const original = host({ protocol: 'telnet' });
const api = await harness(original, { identities: [{ id: 'incomplete-identity', label: 'Incomplete', username: 'proxy-user', authMethod: 'password', created: 0 }] });
t.after(api.close);
assert.equal((await api.find())?.sessionId, 'tab');
await api.update({ host: { ...original, ...proxy } });
assert.equal(await api.find(), undefined);
await api.update({ host: { ...original, label: 'Renamed without a proxy' } });
assert.equal((await api.find())?.sessionId, 'tab');
});
}
test('Telnet inheriting a proxy profile cannot reuse its previous direct session', async (t) => {
const original = host({ protocol: 'telnet', group: 'prod/web' });
const groupConfigs: GroupConfig[] = [];
const proxyProfiles = [{ id: 'group-proxy', label: 'Proxy', createdAt: 0, config: { type: 'socks5' as const, host: 'proxy.invalid', port: 1080 } }];
const resolveEffectiveHost = (candidate: Host) => resolveEffectiveTerminalHost({ host: candidate, groupConfigs, proxyProfiles });
const api = await harness(original, { resolveEffectiveHost });
t.after(api.close);
assert.equal((await api.find())?.sessionId, 'tab');
groupConfigs.push({ path: 'prod', proxyProfileId: 'group-proxy' });
assert.equal(resolveEffectiveHost(original).proxyConfig?.host, 'proxy.invalid');
await api.update({ host: { ...original } });
assert.equal(await api.find(), undefined);
});
@@ -0,0 +1,132 @@
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 { findIncompleteProxyIdentityId, findMissingProxyIdentityId, hasUsableProxyConfig } from '../../domain/proxyProfiles';
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 === 'local') {
const options = {
hostname: host.hostname,
username: '',
localShell: host.localShell || undefined,
// undefined lets the backend choose login arguments; [] explicitly does not.
localShellArgs: host.localShellArgs,
localStartDir: host.localStartDir || undefined,
};
return buildTransferPoolKey({ hostname: host.hostname, hostId: host.id, protocol, connectionOptions: options });
}
if (protocol === 'telnet') {
// The actual Telnet starter rejects these settings before connecting.
if ((host.proxyProfileId && !host.proxyConfig)
|| findMissingProxyIdentityId(host.proxyConfig, identities)
|| findIncompleteProxyIdentityId(host.proxyConfig, identities)
|| hasUsableProxyConfig(host.proxyConfig)) {
throw new Error('Telnet proxy configuration cannot be used');
}
// 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,
localShell: session.localShell ?? host.localShell,
localShellArgs: session.localShellArgs ?? host.localShellArgs,
localStartDir: session.localStartDir ?? host.localStartDir,
}).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;
}
+45 -15
View File
@@ -8,24 +8,27 @@ const fs = require("node:fs");
// but no pathname cleanup may remove a concurrent writer's replacement.
// Resolves with the published inode identity ({ dev, ino, size }) so callers can
// verify an open destination handle before later metadata stamping.
async function publishLocalFileExclusive(source, target, assertNotCancelled = () => {}, preparedHandle) {
async function publishLocalFileExclusive(source, target, assertNotCancelled = () => {}, preparedHandle, options = {}) {
assertNotCancelled();
try {
await fs.promises.link(source, target);
// The hardlink shares the published inode; stat either name.
const linkedStat = await fs.promises.lstat(source);
return { dev: linkedStat.dev, ino: linkedStat.ino, size: linkedStat.size };
} catch (error) {
// EISDIR is the misleading libuv mapping for Win32 ERROR_INVALID_FUNCTION
// when the volume has no hard-link support (exFAT/FAT32); the source here
// is always a regular prepared file, never a directory (nodejs/node#65817).
if (!["ENOTSUP", "EOPNOTSUPP", "ENOSYS", "EPERM", "EACCES", "EXDEV", "EISDIR"].includes(error?.code)) throw error;
if (!options.copyOnly) {
try {
await fs.promises.link(source, target);
// The hardlink shares the published inode; stat either name.
const linkedStat = await fs.promises.lstat(source);
return { dev: linkedStat.dev, ino: linkedStat.ino, size: linkedStat.size };
} catch (error) {
// EISDIR is the misleading libuv mapping for Win32 ERROR_INVALID_FUNCTION
// when the volume has no hard-link support (exFAT/FAT32); the source here
// is always a regular prepared file, never a directory (nodejs/node#65817).
if (!["ENOTSUP", "EOPNOTSUPP", "ENOSYS", "EPERM", "EACCES", "EXDEV", "EISDIR"].includes(error?.code)) throw error;
}
}
let input;
let output;
let failure;
let publishedIdentity;
let timestampsPreserved = true;
try {
input = preparedHandle ?? await fs.promises.open(source, "r");
const stat = await input.stat();
@@ -49,14 +52,31 @@ async function publishLocalFileExclusive(source, target, assertNotCancelled = ()
assertNotCancelled();
// Restore metadata after writes (which may clear special permission bits),
// through the owned handle rather than a potentially replaced pathname.
await output.chmod(stat.mode & 0o7777);
await output.utimes(stat.atime, stat.mtime);
const mode = stat.mode & 0o7777;
if (((await output.stat()).mode & 0o7777) !== mode) {
try {
await output.chmod(mode);
} catch (error) {
// New files follow the mount's access policy (GVFS can synthesize mode
// bits). Replacements must still preserve the validated protection.
if (!isOptionalMetadataError(error) || options.preserveMode !== false) throw error;
}
if (options.preserveMode !== false && ((await output.stat()).mode & 0o7777) !== mode) {
throw new Error("Cannot preserve local destination permissions");
}
}
try {
await output.utimes(stat.atime, stat.mtime);
} catch (error) {
if (!isOptionalMetadataError(error) || options.requireTimestamps !== false) throw error;
timestampsPreserved = false;
}
const ownedStat = await output.stat();
const targetStat = await fs.promises.lstat(target);
if (!targetStat.isFile() || targetStat.dev !== ownedStat.dev || targetStat.ino !== ownedStat.ino) {
throw new Error("Local download target changed during replacement");
}
publishedIdentity = { dev: ownedStat.dev, ino: ownedStat.ino, size: ownedStat.size };
publishedIdentity = { dev: ownedStat.dev, ino: ownedStat.ino, size: ownedStat.size, timestampsPreserved };
} catch (error) {
failure = error;
} finally {
@@ -73,4 +93,14 @@ async function publishLocalFileExclusive(source, target, assertNotCancelled = ()
return publishedIdentity;
}
module.exports = { publishLocalFileExclusive };
// Only optional chmod/utimes may ignore permission refusal. Never use this
// predicate for creation, data writes, close, or required permission changes.
function isOptionalMetadataError(error) {
return isMetadataUnsupportedError(error) || ["EPERM", "EACCES"].includes(error?.code);
}
function isMetadataUnsupportedError(error) {
return ["ENOTSUP", "EOPNOTSUPP", "ENOSYS"].includes(error?.code);
}
module.exports = { publishLocalFileExclusive, isMetadataUnsupportedError };
+36 -6
View File
@@ -727,6 +727,15 @@ function syncLiveSessionsToExternalScope(chatSessionId = EXTERNAL_MCP_CHAT_SESSI
}
return { ok: true, count: 0, chatSessionId };
}
// A reconnecting renderer tab can be absent from the backend map while
// another terminal is still live. Preserve its already scoped metadata;
// authoritative renderer updates remove it when the tab actually closes.
const liveIds = new Set(sessionList.map((entry) => entry.sessionId));
for (const [sessionId, meta] of existingMeta) {
if (!liveIds.has(sessionId) && meta.connected === false) {
sessionList.push({ sessionId, ...meta });
}
}
updateSessionMetadata(sessionList, chatSessionId);
return { ok: true, count: sessionList.length, chatSessionId };
}
@@ -1615,6 +1624,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 +2252,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 +2271,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 +2283,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 +2307,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,162 @@
"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);
});
test("external context preserves reconnecting metadata beside another live transport", async (t) => {
const bridge = setup(t, { sessions: new Map([["online", { hostname: "online", stream: { write() {} } }]]) });
const scope = bridge.EXTERNAL_MCP_CHAT_SESSION_ID;
bridge.setExternalMcpHooks({ isEnabled: () => true });
bridge.updateSessionMetadata([
{ sessionId: "online", hostname: "online", connected: true },
{ sessionId: "retrying", hostname: "retrying", connected: false },
], scope);
const context = await bridge.dispatchBuiltinRpc("netcatty/getContext", { chatSessionId: scope });
assert.equal(context.hostCount, 2);
assert.equal(context.hosts.find(host => host.sessionId === "retrying")?.connected, false);
assert.equal(context.hosts.find(host => host.sessionId === "online")?.connected, true);
// A renderer-authoritative removal must still remove the closed tab.
bridge.updateSessionMetadata([{ sessionId: "online", connected: true }], scope);
const afterClose = await bridge.dispatchBuiltinRpc("netcatty/getContext", { chatSessionId: scope });
assert.deepEqual(afterClose.hosts.map(host => host.sessionId), ["online"]);
});
@@ -560,6 +560,54 @@ describe("AI/MCP SCP transfer abort on shipped download/upload entry points", ()
assert.deepEqual(leftovers, []);
});
it("downloadSftpToLocal cancels and clears the stage before fallback creates output", async (t) => {
const controller = new AbortController();
const payload = Buffer.from("complete-download-before-cancel");
const localPath = path.join(tmpDir, "cancel-before-fallback.bin");
fs.writeFileSync(localPath, "original");
let stagedPath;
t.after(() => { if (stagedPath) fs.rmSync(stagedPath, { force: true }); });
const events = [];
sftpClients.set("scp-pre-fallback-cancel", {
__netcattyFileProtocol: "scp",
__netcattyScpBackend: {
async stat() { return { type: "file", isDirectory: false, size: payload.length }; },
async downloadFile(_remotePath, stage) {
stagedPath = stage;
await fs.promises.writeFile(stage, payload);
return { fileSize: payload.length, transferred: payload.length };
},
},
});
for (const name of ["rename", "copyFile"]) {
const original = fs.promises[name];
t.after(() => { fs.promises[name] = original; });
fs.promises[name] = async (...args) => {
if (args[0] === stagedPath && String(args[1]).endsWith(".ready")) {
if (name === "copyFile") controller.abort();
throw Object.assign(new Error("injected cross-device copy refusal"), {
code: name === "rename" ? "EXDEV" : "ENOTSUP",
});
}
return original.apply(fs.promises, args);
};
}
await assert.rejects(() => sftpBridge.downloadSftpToLocal(null, {
sftpId: "scp-pre-fallback-cancel", remotePath: "/remote/file.bin", localPath,
abortSignal: controller.signal,
onTransferEvent: (channel) => events.push(channel),
}), (error) => {
assert.match(error.message, /cancel|abort/i);
assert.doesNotMatch(error.message, /Recovery files preserved/);
return true;
});
assert.ok(stagedPath);
assert.equal(fs.existsSync(stagedPath), false);
assert.equal(fs.readFileSync(localPath, "utf8"), "original");
assert.ok(events.includes("netcatty:transfer:cancelled"));
assert.equal(events.includes("netcatty:transfer:error"), false);
});
it("downloadSftpToLocal leaves the original untouched when mode setup fails", {
skip: process.platform === "win32",
}, async (t) => {
@@ -582,16 +630,11 @@ describe("AI/MCP SCP transfer abort on shipped download/upload entry points", ()
await fs.promises.writeFile(localPath, original, { mode: 0o600 });
await fs.promises.chmod(localPath, 0o600);
const originalChmod = fs.promises.chmod;
fs.promises.chmod = async (filePath, mode) => {
if (String(filePath).includes(".netcatty-") && String(filePath).endsWith(".ready")) {
const error = new Error("injected chmod failure");
error.code = "EPERM";
throw error;
}
return originalChmod(filePath, mode);
};
t.after(() => { fs.promises.chmod = originalChmod; });
interceptPreparedChmod(t, async () => {
const error = new Error("injected chmod failure");
error.code = "EPERM";
throw error;
});
await assert.rejects(
() => sftpBridge.downloadSftpToLocal(null, {
@@ -630,18 +673,14 @@ describe("AI/MCP SCP transfer abort on shipped download/upload entry points", ()
const original = Buffer.from("original-before-late-change");
await fs.promises.writeFile(localPath, original, { mode: 0o600 });
const originalChmod = fs.promises.chmod;
let releaseChmod;
let markChmodStarted;
const chmodStarted = new Promise((resolve) => { markChmodStarted = resolve; });
fs.promises.chmod = async (filePath, mode) => {
if (String(filePath).includes(".netcatty-") && String(filePath).endsWith(".ready")) {
markChmodStarted();
await new Promise((resolve) => { releaseChmod = resolve; });
}
return originalChmod(filePath, mode);
};
t.after(() => { fs.promises.chmod = originalChmod; });
interceptPreparedChmod(t, async (chmod, mode) => {
markChmodStarted();
await new Promise((resolve) => { releaseChmod = resolve; });
return chmod(mode);
});
const running = sftpBridge.downloadSftpToLocal(null, {
sftpId: "scp-late-directory",
@@ -685,18 +724,14 @@ describe("AI/MCP SCP transfer abort on shipped download/upload entry points", ()
await fs.promises.writeFile(localPath, original, { mode: 0o600 });
await fs.promises.chmod(localPath, 0o600);
const originalChmod = fs.promises.chmod;
let releaseChmod;
let markChmodStarted;
const chmodStarted = new Promise((resolve) => { markChmodStarted = resolve; });
fs.promises.chmod = async (filePath, mode) => {
if (String(filePath).includes(".netcatty-") && String(filePath).endsWith(".ready")) {
markChmodStarted();
await new Promise((resolve) => { releaseChmod = resolve; });
}
return originalChmod(filePath, mode);
};
t.after(() => { fs.promises.chmod = originalChmod; });
interceptPreparedChmod(t, async (chmod, mode) => {
markChmodStarted();
await new Promise((resolve) => { releaseChmod = resolve; });
return chmod(mode);
});
const running = sftpBridge.downloadSftpToLocal(null, {
sftpId: "scp-same-mode-replacement",
@@ -986,3 +1021,17 @@ describe("AI/MCP SCP transfer abort on shipped download/upload entry points", ()
assert.match(unifiedBlock, /transferBridge\.cancelTransfer/);
});
});
function interceptPreparedChmod(t, callback) {
const originalOpen = fs.promises.open;
fs.promises.open = async (filePath, ...args) => {
const handle = await originalOpen.call(fs.promises, filePath, ...args);
if (String(filePath).includes(".netcatty-") && String(filePath).endsWith(".ready") && args[0] === "r") {
const chmod = handle.chmod.bind(handle);
handle.chmod = (mode) => callback(chmod, mode);
}
return handle;
};
t.after(() => { fs.promises.open = originalOpen; });
}
@@ -0,0 +1,193 @@
"use strict";
const test = require("node:test");
const assert = require("node:assert/strict");
const crypto = require("node:crypto");
const fs = require("node:fs");
const net = require("node:net");
const os = require("node:os");
const path = require("node:path");
const { Server } = require("ssh2");
const bridge = require("./sshBridge.cjs");
const tempDirBridge = require("./tempDirBridge.cjs");
const listen = (server, ...args) => new Promise((resolve, reject) => {
server.once("error", reject);
server.listen(...args, resolve);
});
const close = (server) => new Promise((resolve) => server.close(resolve));
test("normal SSH startup preserves fallback after an unavailable system agent", {
skip: process.platform === "win32", // This fixture exercises Unix agent sockets.
timeout: 20_000,
}, async (t) => {
const fixture = fs.mkdtempSync(`${tempDirBridge.getTempFilePath("a")}-`);
const originalHomedir = os.homedir;
const originalAgentSocket = process.env.SSH_AUTH_SOCK;
os.homedir = () => fixture; // Never discover the developer's default keys.
t.after(() => {
os.homedir = originalHomedir;
if (originalAgentSocket === undefined) delete process.env.SSH_AUTH_SOCK;
else process.env.SSH_AUTH_SOCK = originalAgentSocket;
fs.rmSync(fixture, { recursive: true, force: true });
});
const hostKey = crypto.generateKeyPairSync("rsa", {
modulusLength: 2048,
privateKeyEncoding: { type: "pkcs1", format: "pem" },
publicKeyEncoding: { type: "spki", format: "pem" },
}).privateKey;
let sequence = 0;
async function run({ stale = true, useSshAgent, password = "fixture-password", stall = false, jump = false } = {}) {
const senderId = 3640 + sequence++;
const socketPath = path.join(fixture, "s");
fs.rmSync(socketPath, { force: true });
const agentClients = new Set();
let agentChecks = 0;
const agent = net.createServer((socket) => {
agentClients.add(socket);
agentChecks += 1;
socket.on("error", () => {});
socket.on("close", () => agentClients.delete(socket));
// SSH_AGENT_IDENTITIES_ANSWER with zero keys.
socket.once("data", () => socket.end(Buffer.from([0, 0, 0, 5, 12, 0, 0, 0, 0])));
});
await listen(agent, socketPath);
if (stale) {
// Preserve a real socket entry with no listener, without a timing race.
fs.renameSync(socketPath, socketPath + ".stale");
await close(agent);
fs.renameSync(socketPath + ".stale", socketPath);
assert.ok(fs.statSync(socketPath).isSocket());
}
process.env.SSH_AUTH_SOCK = socketPath;
const sshClients = new Set();
const forwardingSockets = new Set();
const methods = [];
let shells = 0;
const ssh = new Server({ hostKeys: [hostKey] }, (client) => {
sshClients.add(client);
client.on("error", () => {});
client.on("close", () => sshClients.delete(client));
client.on("tcpip", (accept, reject, info) => {
if (info.destIP !== "127.0.0.1" || info.destPort !== ssh.address().port) return reject();
const socket = net.connect(info.destPort, info.destIP, () => {
const stream = accept();
stream.on("error", () => socket.destroy());
stream.pipe(socket).pipe(stream);
});
forwardingSockets.add(socket);
socket.on("close", () => forwardingSockets.delete(socket));
socket.on("error", () => reject());
});
client.on("authentication", (ctx) => {
methods.push(ctx.method);
if (ctx.method === "password" && stall) return;
if (ctx.method === "password" && ctx.password === "fixture-password") ctx.accept();
else ctx.reject(["publickey", "password"]);
});
client.on("ready", () => client.on("session", (accept) => {
const session = accept();
session.on("pty", (acceptPty) => acceptPty());
session.on("shell", (acceptShell) => {
shells += 1;
acceptShell().write("fixture ready\r\n");
});
}));
});
await listen(ssh, 0, "127.0.0.1");
const handlers = new Map();
const sent = [];
bridge.init({ sessions: new Map(), electronModule: {} });
bridge.registerHandlers({ handle: (name, fn) => handlers.set(name, fn), on() {} });
const event = { sender: { id: senderId, isDestroyed: () => false,
send: (channel, payload) => sent.push({ channel, payload }) } };
let guard;
try {
const start = handlers.get("netcatty:start")(event, {
sessionId: `agent-fallback-${senderId}`, hostname: "127.0.0.1", port: ssh.address().port,
username: "fixture", password, authMethod: "auto", useSshAgent,
verifyHostKeys: false, reuseTransport: false, skipShellPidDiscovery: true,
sshTcpConnectTimeoutMs: 1000, sshAuthReadyTimeoutMs: 1000,
...(jump ? { jumpHosts: [{
hostname: "127.0.0.1", port: ssh.address().port, username: "fixture",
password, authMethod: "auto", useSshAgent,
sshTcpConnectTimeoutMs: 1000, sshAuthReadyTimeoutMs: 1000,
}] } : {}),
});
let error;
try {
await Promise.race([start, new Promise((_, reject) => {
guard = setTimeout(() => reject(new Error("Fixture exceeded authentication deadline")), 4000);
})]);
} catch (err) { error = err; }
return { error, methods, shells, sent: sent.slice(), agentChecks };
} finally {
clearTimeout(guard);
for (const client of sshClients) client.end();
for (const socket of forwardingSockets) socket.destroy();
for (const client of agentClients) client.destroy();
if (agent.listening) await close(agent);
await close(ssh);
}
}
for (const [name, options] of [
["stale agent", {}],
["disabled agent", { useSshAgent: false }],
["healthy empty agent", { stale: false }],
]) {
await t.test(`${name} still opens a real shell using the saved password`, async () => {
const result = await run(options);
assert.equal(result.error, undefined);
assert.deepEqual(result.methods, ["none", "password"]);
assert.equal(result.shells, 1);
assert.equal(result.agentChecks, options.stale === false ? 1 : 0);
assert.equal(result.sent.some(({ channel }) => channel === "netcatty:auth:failed"), false);
assert.equal(result.sent.some(({ channel }) => channel === "netcatty:exit"), false);
});
}
for (const [name, password, methods] of [
["agent without other credentials", undefined, ["none"]],
["incorrect password", "wrong-password", ["none", "password"]],
]) {
await t.test(`${name} still reports a real authentication failure`, async () => {
// null deliberately omits a password rather than taking run's default.
const result = await run({ password: password ?? null });
assert.equal(result.error?.level, "client-authentication");
assert.match(result.error.message, /All configured authentication methods failed/);
assert.deepEqual(result.methods, methods);
assert.equal(result.shells, 0);
assert.ok(result.sent.some(({ channel }) => channel === "netcatty:auth:failed"));
});
}
await t.test("stalled password fallback retains its authentication deadline", async () => {
const result = await run({ stall: true });
assert.match(result.error?.message || "", /Connection timeout to 127\.0\.0\.1/);
assert.deepEqual(result.methods, ["none", "password"]);
assert.equal(result.shells, 0);
assert.ok(result.sent.some(({ channel, payload }) => (
channel === "netcatty:exit" && payload.reason === "timeout"
)));
});
await t.test("a stale jump-host agent still reaches a shell through saved passwords", async () => {
const result = await run({ jump: true });
assert.equal(result.error, undefined);
assert.deepEqual(result.methods, ["none", "password", "none", "password"]);
assert.equal(result.shells, 1);
assert.equal(result.sent.some(({ channel }) => channel === "netcatty:auth:failed"), false);
});
await t.test("a fatal jump-host auth error after its agent error still rejects", async () => {
const result = await run({ jump: true, password: "wrong-password" });
assert.equal(result.error?.level, "client-authentication");
assert.equal(result.error?.isJumpHostAuthError, true);
assert.deepEqual(result.methods, ["none", "password"]);
assert.equal(result.shells, 0);
});
await t.test("a stalled jump-host password retains its authentication deadline", async () => {
const result = await run({ jump: true, stall: true });
assert.match(result.error?.message || "", /Connection timeout to 127\.0\.0\.1/);
assert.deepEqual(result.methods, ["none", "password"]);
assert.equal(result.shells, 0);
});
});
+6 -1
View File
@@ -813,7 +813,12 @@ async function connectThroughChain(event, options, jumpHosts, targetHost, target
}
resolve();
});
conn.once('error', (err) => {
conn.on('error', (err) => {
// ssh2 continues to the next auth method after agent errors.
if (err.level === 'agent') {
console.log(`[Chain] Hop ${i + 1} non-fatal agent auth error (will try next method):`, err.message);
return;
}
if (settled) return;
settled = true;
clearAuthReadyTimer();
@@ -2481,6 +2481,16 @@ function createStartSessionApi(ctx) {
});
conn.on("error", (err) => {
// ssh2 reports agent failures before trying the next auth method.
// Keep the connection and its authentication deadline alive.
if (err.level === "agent") {
log("non-fatal agent auth error (will try next method)", {
sessionId,
hostname: options.hostname,
error: err.message,
});
return;
}
clearAuthReadyTimer();
// After the promise is settled, we can't reject again. But if the
// session was already established (resolved), we still need to notify
+41 -6
View File
@@ -1040,7 +1040,7 @@ function stableLocalFileIdentity(statLike) {
}
async function promoteLocalTransfer(stagedPath, targetPath, options = {}) {
const { publishLocalFileExclusive } = require("./localFilePublish.cjs");
const { publishLocalFileExclusive, isMetadataUnsupportedError } = require("./localFilePublish.cjs");
const assertNotCancelled = options.assertNotCancelled || (() => {});
const token = crypto.randomUUID().replace(/-/g, "");
const base = path.join(path.dirname(targetPath), `.${path.basename(targetPath)}.netcatty-${token}`);
@@ -1050,6 +1050,7 @@ async function promoteLocalTransfer(stagedPath, targetPath, options = {}) {
let backedUp = false;
let committed = false;
let keepRecoveryFiles = false;
let skipReadyCleanup = false;
let preparedHandle;
let originalHandle;
let restoreProbeCreated = false;
@@ -1060,7 +1061,28 @@ async function promoteLocalTransfer(stagedPath, targetPath, options = {}) {
await fs.promises.rename(stagedPath, readyPath);
} catch (error) {
if (error?.code !== "EXDEV") throw error;
await fs.promises.copyFile(stagedPath, readyPath, fs.constants.COPYFILE_EXCL);
try {
await fs.promises.copyFile(stagedPath, readyPath, fs.constants.COPYFILE_EXCL);
} catch (copyError) {
if (!["ENOTSUP", "EOPNOTSUPP"].includes(copyError?.code)) throw copyError;
// copyFile includes metadata operations that writable GVFS mounts may
// reject. Reuse the exclusive bounded read/write publication path.
try {
await publishLocalFileExclusive(stagedPath, readyPath, assertNotCancelled, undefined, {
copyOnly: true, preserveMode: false, requireTimestamps: false,
});
} catch (fallbackError) {
// Do not remove a name this fallback never owned: copyFile may
// have left a partial, or an exclusive open may have lost a race.
skipReadyCleanup = true;
const cancelledBeforeOutput = !fallbackError.localPublicationIncomplete
&& (fallbackError.code === "ABORT_ERR" || fallbackError.message === "Transfer cancelled");
// An early cancellation needs ordinary stage cleanup, not recovery.
// Once output exists, retain the complete stage as before.
keepRecoveryFiles = !cancelledBeforeOutput;
throw fallbackError;
}
}
}
// Stamp the private prepared file before applying possibly unreadable
// destination permissions. Publication carries these times to the target.
@@ -1085,7 +1107,17 @@ async function promoteLocalTransfer(stagedPath, targetPath, options = {}) {
? validatedTarget.existingMode & 0o7777
: Number.isInteger(options.existingMode) ? options.existingMode & 0o7777 : null;
if (mode !== null && mode !== appliedMode) {
await fs.promises.chmod(readyPath, mode);
if (((await preparedHandle.stat()).mode & 0o7777) !== mode) {
try {
await preparedHandle.chmod(mode);
} catch (error) {
if (!isMetadataUnsupportedError(error)
|| ((await preparedHandle.stat()).mode & 0o7777) !== mode) throw error;
}
if (((await preparedHandle.stat()).mode & 0o7777) !== mode) {
throw new Error("Cannot preserve local destination permissions");
}
}
appliedMode = mode;
continue;
}
@@ -1131,7 +1163,9 @@ async function promoteLocalTransfer(stagedPath, targetPath, options = {}) {
assertNotCancelled();
let publishedIdentity = null;
try {
publishedIdentity = await publishLocalFileExclusive(readyPath, targetPath, assertNotCancelled, preparedHandle) ?? null;
publishedIdentity = await publishLocalFileExclusive(readyPath, targetPath, assertNotCancelled, preparedHandle, {
preserveMode: appliedMode !== null, requireTimestamps: false,
}) ?? null;
} catch (error) {
if (error?.code === "EEXIST") {
throw new Error("Local download target changed during replacement", { cause: error });
@@ -1144,12 +1178,13 @@ async function promoteLocalTransfer(stagedPath, targetPath, options = {}) {
committed = true;
// Hand the published inode identity to the caller for descriptor-based
// metadata stamping after publication.
options.onCommit?.(publishedIdentity, localMtimePrepared);
options.onCommit?.(publishedIdentity, localMtimePrepared && publishedIdentity?.timestampsPreserved !== false);
if (backedUp) await fs.promises.unlink(backupPath).catch(() => {});
await fs.promises.unlink(readyPath).catch(() => {});
await fs.promises.unlink(stagedPath).catch(() => {});
} catch (error) {
if (committed) throw error;
if (error?.localPublicationIncomplete) keepRecoveryFiles = true;
if (backedUp && !keepRecoveryFiles) {
try {
// The pathname may have changed between open and rename. Never copy
@@ -1179,7 +1214,7 @@ async function promoteLocalTransfer(stagedPath, targetPath, options = {}) {
if (backedUp) failure.remoteBackupPath = backupPath;
throw failure;
}
await fs.promises.unlink(readyPath).catch(() => {});
if (!skipReadyCleanup) await fs.promises.unlink(readyPath).catch(() => {});
throw error;
} finally {
await preparedHandle?.close().catch(() => {});
@@ -0,0 +1,189 @@
"use strict";
const test = require("node:test");
const assert = require("node:assert/strict");
const fs = require("node:fs");
const path = require("node:path");
const bridge = require("./transferBridge.cjs");
const temp = require("./tempDirBridge.cjs");
const unsupported = (code = "ENOTSUP") => Object.assign(new Error(`Injected ${code}`), { code });
// Real files and handles; only unavailable mount operations and virtual mode
// reporting are simulated. This is not a real Linux/GVFS acceptance test.
async function mountedDestination(t, options = {}) {
const root = fs.mkdtempSync(`${temp.getTempFilePath("gvfs-publish")}-`);
t.after(() => fs.rmSync(root, { recursive: true, force: true }));
const stage = path.join(root, "stage.part");
const target = path.join(root, "target");
const payload = Buffer.alloc(2 * 1024 * 1024 + 17, 173);
fs.writeFileSync(stage, payload, { mode: 0o644 });
if (options.existingMode !== undefined) {
fs.writeFileSync(target, "original");
fs.chmodSync(target, options.existingMode);
}
const calls = [];
let cancelled = false;
for (const name of ["rename", "copyFile", "link", "open"]) {
const original = fs.promises[name];
t.after(() => { fs.promises[name] = original; });
fs.promises[name] = async (...args) => {
if (name === "rename" && args[0] === stage) throw unsupported("EXDEV");
if (name === "copyFile") {
calls.push("copyFile");
if (options.readyCollision) fs.writeFileSync(args[1], "other writer");
if (options.cancelBeforeFallback) cancelled = true;
if (options.copyError !== false) throw unsupported(options.copyError || "ENOTSUP");
}
if (name === "link") throw unsupported();
const result = await original.apply(fs.promises, args);
if (name !== "open" || args[0] === stage) return result;
const created = args[1] === "wx";
const virtualMode = args[0] === target ? options.finalVirtualMode ?? options.virtualMode : options.virtualMode;
if (created && virtualMode !== undefined) fs.chmodSync(args[0], virtualMode);
result.chmod = async () => { calls.push("chmod"); throw unsupported(options.metadataError); };
result.utimes = async () => { calls.push("utimes"); throw unsupported(options.metadataError); };
if (created) {
const final = args[0] === target;
calls.push(final ? "final-open" : "ready-open");
const write = result.write.bind(result);
result.write = async (buffer, offset, length, position) => {
if (options.writeError && final) throw unsupported(options.writeError);
const written = await write(buffer, offset, Math.min(length, 32768), position);
if (options.cancel && !final) cancelled = true;
return written;
};
const close = result.close.bind(result);
result.close = async () => {
await close();
if (options.closeError === (final ? "final" : "ready")) throw unsupported("EIO");
};
}
return result;
};
}
const run = () => bridge._promoteLocalTransferForTests(stage, target, {
sourceSoftIdentity: { mtimeMs: 1700000000000 },
validateTarget: async () => ({
targetIdentity: options.existingMode === undefined ? "missing" : undefined,
existingMode: options.existingMode,
}),
assertNotCancelled: () => {
if (cancelled || (options.cancelBeforeFinal && !fs.existsSync(target))) throw unsupported("ABORT_ERR");
},
onCommit: (_identity, timestampsPreserved) => { calls.push(["commit", timestampsPreserved]); },
});
return { root, stage, target, payload, calls, run };
}
for (const [virtualMode, copyError, metadataError] of [[0o644, "ENOTSUP"], [0o700, "EOPNOTSUPP"], [0o700, "ENOTSUP", "EPERM"], [0o700, "ENOTSUP", "EACCES"]]) {
test(`new mounted destination accepts creation policy ${virtualMode.toString(8)} with ${metadataError || "ENOTSUP"} through both copy stages`, async (t) => {
const setup = await mountedDestination(t, { virtualMode, copyError, metadataError });
await setup.run();
assert.deepEqual(fs.readFileSync(setup.target), setup.payload);
assert.deepEqual(fs.readdirSync(setup.root), ["target"]);
assert.ok(setup.calls.includes("ready-open"));
assert.ok(setup.calls.includes("final-open"));
assert.deepEqual(setup.calls.at(-1), ["commit", false]);
});
}
for (const metadataError of ["ENOTSUP", "EPERM", "EACCES"]) {
test(`existing mounted file keeps matching virtual permissions: ${metadataError}`, async (t) => {
const setup = await mountedDestination(t, { virtualMode: 0o700, existingMode: 0o700, metadataError });
await setup.run();
assert.deepEqual(fs.readFileSync(setup.target), setup.payload);
assert.equal(fs.statSync(setup.target).mode & 0o7777, 0o700);
assert.deepEqual(fs.readdirSync(setup.root), ["target"]);
});
test(`refused chmod cannot broaden an existing destination: ${metadataError}`, async (t) => {
const setup = await mountedDestination(t, { virtualMode: 0o700, existingMode: 0o600, metadataError });
await assert.rejects(setup.run, { code: metadataError });
assert.equal(fs.readFileSync(setup.target, "utf8"), "original");
assert.equal(fs.statSync(setup.target).mode & 0o7777, 0o600);
assert.deepEqual(fs.readFileSync(setup.stage), setup.payload);
});
}
for (const copyError of ["EACCES", "EPERM", "ENOSPC", "EIO", "EEXIST", "EINVAL"]) {
test(`copyFile ${copyError} remains a failure`, async (t) => {
const setup = await mountedDestination(t, { copyError });
await assert.rejects(setup.run, { code: copyError });
assert.equal(fs.existsSync(setup.target), false);
assert.deepEqual(fs.readFileSync(setup.stage), setup.payload);
assert.equal(setup.calls.includes("ready-open"), false);
});
}
for (const failure of [{ cancel: true }, { closeError: "ready" }, { closeError: "final" }, { writeError: "ENOSPC" }, { writeError: "EPERM" }, { writeError: "EACCES" }]) {
test(`copy failure preserves original and complete stage: ${JSON.stringify(failure)}`, async (t) => {
const setup = await mountedDestination(t, { existingMode: 0o644, virtualMode: 0o644, ...failure });
await assert.rejects(setup.run, /Recovery files preserved/);
assert.deepEqual(fs.readFileSync(setup.stage), setup.payload);
const backup = fs.readdirSync(setup.root).find(name => name.endsWith(".backup"));
assert.equal(fs.readFileSync(backup ? path.join(setup.root, backup) : setup.target, "utf8"), "original");
assert.equal(setup.calls.some(call => Array.isArray(call) && call[0] === "commit"), false);
});
}
test("failed copy followed by exclusive-open collision leaves that entry intact", async (t) => {
const setup = await mountedDestination(t, { readyCollision: true });
await assert.rejects(setup.run, /Recovery files preserved/);
const ready = fs.readdirSync(setup.root).find(name => name.endsWith(".ready"));
assert.equal(fs.readFileSync(path.join(setup.root, ready), "utf8"), "other writer");
assert.deepEqual(fs.readFileSync(setup.stage), setup.payload);
assert.equal(fs.existsSync(setup.target), false);
});
for (const metadataError of ["ENOTSUP", "EPERM", "EACCES"]) {
test(`rollback retains backup when its original timestamps cannot be restored: ${metadataError}`, async (t) => {
const setup = await mountedDestination(t, { existingMode: 0o644, cancelBeforeFinal: true, metadataError });
await assert.rejects(setup.run, /Recovery files preserved/);
const backup = fs.readdirSync(setup.root).find(name => name.endsWith(".backup"));
assert.ok(backup);
assert.equal(fs.readFileSync(path.join(setup.root, backup), "utf8"), "original");
assert.deepEqual(fs.readFileSync(setup.stage), setup.payload);
});
}
for (const metadataError of ["EPERM", "EACCES"]) {
test(`final copy cannot broaden an existing destination after chmod ${metadataError}`, async (t) => {
const setup = await mountedDestination(t, {
existingMode: 0o644, virtualMode: 0o644, finalVirtualMode: 0o700, metadataError,
});
await assert.rejects(setup.run, /Recovery files preserved/);
const backup = fs.readdirSync(setup.root).find(name => name.endsWith(".backup"));
assert.ok(backup);
assert.equal(fs.readFileSync(path.join(setup.root, backup), "utf8"), "original");
assert.deepEqual(fs.readFileSync(setup.stage), setup.payload);
assert.equal(setup.calls.some(call => Array.isArray(call) && call[0] === "commit"), false);
});
}
for (const readyCollision of [false, true]) {
test(`cancellation before fallback output remains cancellation, existing ready=${readyCollision}`, async (t) => {
const setup = await mountedDestination(t, { cancelBeforeFallback: true, readyCollision });
const originalLstat = fs.promises.lstat;
const originalUnlink = fs.promises.unlink;
t.after(() => { fs.promises.lstat = originalLstat; fs.promises.unlink = originalUnlink; });
for (const [name, original] of [["lstat", originalLstat], ["unlink", originalUnlink]]) {
fs.promises[name] = async (...args) => {
assert.equal(String(args[0]).endsWith(".ready"), false, "no destination probe/cleanup after early cancellation");
return original.apply(fs.promises, args);
};
}
await assert.rejects(setup.run, (error) => {
assert.equal(error.code, "ABORT_ERR");
assert.equal(Boolean(error.recoveryFailed), false);
return true;
});
assert.equal(setup.calls.includes("ready-open"), false);
assert.equal(fs.existsSync(setup.target), false);
if (readyCollision) {
const ready = fs.readdirSync(setup.root).find(name => name.endsWith(".ready"));
assert.equal(fs.readFileSync(path.join(setup.root, ready), "utf8"), "other writer");
} else assert.deepEqual(fs.readdirSync(setup.root), ["stage.part"]);
});
}
+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,146 @@
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);
// Current-main fields only: saved-local creation/default snapshots remain #3616's responsibility.
tabs.length = 0;
host.protocol = 'local';
host.hostname = 'localhost';
host.localShell = '/bin/zsh';
host.localShellArgs = ['-l'];
host.localStartDir = '/original';
const local = await run({ hostId: host.id });
tabs[0].status = 'connected';
assert.equal((await run({ hostId: host.id })).sessionId, local.sessionId);
host.localStartDir = '/replacement';
const changedLocal = await run({ hostId: host.id });
assert.notEqual(changedLocal.sessionId, local.sessionId, 'an explicit saved local directory change must not reuse the old process');
assert.equal(changedLocal.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,