Compare commits

...
5 changed files with 233 additions and 7 deletions

No files matched your search

@@ -2,9 +2,9 @@ import assert from 'node:assert/strict';
import { test } from 'node:test';
import React from 'react';
import { act, create, type ReactTestRenderer } from 'react-test-renderer';
import type { Host, Identity, SSHKey, TerminalSession } from '../../domain/models';
import type { GroupConfig, Host, Identity, SSHKey, TerminalSession } from '../../domain/models';
import { updateRestoredSessionStatusState } from './sessionRestoreState';
import { resolveTerminalSessionHost } from '../../domain/terminalHostResolution';
import { resolveEffectiveTerminalHost, resolveTerminalSessionHost } from '../../domain/terminalHostResolution';
import { createHostTerminalSession } from './sessionFactories';
import { useHostOpenSessionReuse } from './useHostOpenSessionReuse';
@@ -14,14 +14,14 @@ 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[] } = {}) {
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: effective });
find = useHostOpenSessionReuse({ sessions, hosts, keys, identities, resolveEffectiveHost: extra.resolveEffectiveHost ?? effective });
return null;
}
let renderer: ReactTestRenderer;
@@ -146,3 +146,129 @@ test('an unchanged restored plugin configuration can be reused after manual reco
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);
});
+50 -3
View File
@@ -1,6 +1,10 @@
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';
@@ -17,9 +21,45 @@ type HostOpenSessionReuseInput = {
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,
});
@@ -28,13 +68,17 @@ export function useHostOpenSessionReuse({ sessions, hosts, keys, identities, res
for (const hop of options.jumpHosts ?? []) delete hop.label;
const connectionOptions = {
...options,
protocol: host.protocol || 'ssh',
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: host.protocol === 'serial' ? createHostTerminalSession('', host).serialConfig : undefined,
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,
};
@@ -54,13 +98,16 @@ export function useHostOpenSessionReuse({ sessions, hosts, keys, identities, res
openingKeys.current.set(session.id, connectionKey({
...resolveEffectiveHost(host),
hostname: session.hostname,
username: session.username,
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));
+9
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 };
}
@@ -142,3 +142,21 @@ test("getContext still hides disconnected sessions outside the agent scope", asy
});
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"]);
});
@@ -87,6 +87,32 @@ test('MCP host_open forwards the schema input through RPC and vault service to t
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';