mirror of
https://github.com/binaricat/Netcatty.git
synced 2026-10-30 02:21:47 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cd15958256 | ||
|
|
0b8b8a5623 |
No files matched your search
@@ -7,7 +7,6 @@ import {
|
||||
handleKeyboardInteractiveSubmitImpl,
|
||||
handleTrayPanelConnectRequestImpl,
|
||||
} from './app/AppHandlers.ts';
|
||||
import { resolveShellSetting } from '../lib/useDiscoveredShells.ts';
|
||||
import type { Host } from '../types';
|
||||
|
||||
const baseHost: Host = {
|
||||
@@ -399,69 +398,3 @@ test('keyboard-interactive submit keeps the prompt and password unchanged when d
|
||||
assert.equal(hosts[0].password, 'old-password');
|
||||
assert.deepEqual(errors, ['Request not found']);
|
||||
});
|
||||
|
||||
test('local host without explicit shell resolves the configured Settings shell', () => {
|
||||
const connectedHosts: Host[] = [];
|
||||
const localHost: Host = { ...baseHost, id: 'local-1', hostname: 'localhost', protocol: 'local' };
|
||||
const discoveredShells = [
|
||||
{ id: 'wsl-ubuntu', name: 'Ubuntu (WSL)', command: 'wsl.exe', args: ['-d', 'Ubuntu'] },
|
||||
];
|
||||
|
||||
handleConnectToHostImpl(
|
||||
() => ({
|
||||
addConnectionLog: () => {},
|
||||
connectToHost: (host: Host) => {
|
||||
connectedHosts.push(host);
|
||||
return 'local-session';
|
||||
},
|
||||
discoveredShells,
|
||||
identities: [],
|
||||
keys: [],
|
||||
resolveEffectiveHost: (host: Host) => host,
|
||||
resolveHostAuth: () => ({ username: '' }),
|
||||
resolveShellSetting,
|
||||
systemInfoRef: { current: { username: 'local-user', hostname: 'local-host' } },
|
||||
terminalSettings: { localShell: 'wsl-ubuntu' },
|
||||
}) as never,
|
||||
localHost,
|
||||
);
|
||||
|
||||
assert.equal(connectedHosts.length, 1);
|
||||
assert.equal(connectedHosts[0].localShell, 'wsl.exe');
|
||||
assert.deepEqual(connectedHosts[0].localShellArgs, ['-d', 'Ubuntu']);
|
||||
assert.equal(connectedHosts[0].localShellName, 'Ubuntu (WSL)');
|
||||
});
|
||||
|
||||
test('local host with an explicit shell keeps it (no Settings fallback)', () => {
|
||||
const connectedHosts: Host[] = [];
|
||||
const localHost: Host = {
|
||||
...baseHost,
|
||||
id: 'local-2',
|
||||
hostname: 'localhost',
|
||||
protocol: 'local',
|
||||
localShell: 'pwsh',
|
||||
};
|
||||
|
||||
handleConnectToHostImpl(
|
||||
() => ({
|
||||
addConnectionLog: () => {},
|
||||
connectToHost: (host: Host) => {
|
||||
connectedHosts.push(host);
|
||||
return 'local-session-2';
|
||||
},
|
||||
discoveredShells: [],
|
||||
identities: [],
|
||||
keys: [],
|
||||
resolveEffectiveHost: (host: Host) => host,
|
||||
resolveHostAuth: () => ({ username: '' }),
|
||||
resolveShellSetting,
|
||||
systemInfoRef: { current: { username: 'local-user', hostname: 'local-host' } },
|
||||
terminalSettings: { localShell: 'wsl-ubuntu' },
|
||||
}) as never,
|
||||
localHost,
|
||||
);
|
||||
|
||||
assert.equal(connectedHosts.length, 1);
|
||||
assert.equal(connectedHosts[0].localShell, 'pwsh');
|
||||
assert.equal(connectedHosts[0].localShellName, undefined);
|
||||
});
|
||||
@@ -4,7 +4,6 @@ import type { Host, HostProtocol, TerminalSession } from '../../types';
|
||||
import type { PassphraseRequest } from '../../components/PassphraseModal';
|
||||
import type { TerminalPopupPayload } from '../../domain/systemManager/types';
|
||||
import { getEffectiveHostDistro, classifyDistroId, hostRestrictsExtraSshChannels, shouldProbeSessionCwd } from '../../domain/host';
|
||||
import { resolveHostDefaultLocalShell } from '../../domain/localShellHost';
|
||||
import { getAvailablePaneMagnificationController } from '../../domain/paneMagnification';
|
||||
import { sanitizeHostIconFields } from '../../domain/hostIcon';
|
||||
import { resolveEffectiveTerminalProtocol } from '../../domain/terminalProtocol';
|
||||
@@ -205,10 +204,7 @@ export function handleTrayPanelConnectImpl(getCtx: AppContextGetter, hostId: str
|
||||
hostId: host.id,
|
||||
hostLabel: host.label,
|
||||
hostname: host.hostname,
|
||||
// Local-shell vault hosts carry no SSH credentials, so log the system
|
||||
// user like local terminals do (see handleConnectToHostImpl) instead of
|
||||
// 'root' or an inherited group SSH username.
|
||||
username: protocol === 'local' ? username : (resolvedAuth.username || 'root'),
|
||||
username: resolvedAuth.username || 'root',
|
||||
protocol,
|
||||
...getLogHostVisualSnapshot(effectiveHost),
|
||||
startTime: Date.now(),
|
||||
@@ -1108,15 +1104,11 @@ export function handleCreateLocalTerminalImpl(
|
||||
}
|
||||
|
||||
export function handleConnectToHostImpl(getCtx: AppContextGetter, host: Host, hidden = false) {
|
||||
const { addConnectionLog, connectToHost, discoveredShells, identities, keys, resolveEffectiveHost, resolveHostAuth, resolveShellSetting, systemInfoRef, terminalSettings } = getCtx();
|
||||
const { addConnectionLog, connectToHost, identities, keys, resolveEffectiveHost, resolveHostAuth, systemInfoRef } = getCtx();
|
||||
{
|
||||
const { username, hostname: localHost } = systemInfoRef.current;
|
||||
|
||||
const effectiveHost = resolveHostDefaultLocalShell(resolveEffectiveHost(host), {
|
||||
discoveredShells,
|
||||
resolveShellSetting,
|
||||
terminalSettings,
|
||||
});
|
||||
const effectiveHost = resolveEffectiveHost(host);
|
||||
|
||||
// Handle serial hosts separately
|
||||
if (effectiveHost.protocol === 'serial') {
|
||||
@@ -1146,10 +1138,7 @@ export function handleConnectToHostImpl(getCtx: AppContextGetter, host: Host, hi
|
||||
hostId: host.id,
|
||||
hostLabel: host.label,
|
||||
hostname: host.hostname,
|
||||
// Local-shell vault hosts carry no SSH credentials, so log the system
|
||||
// user like local terminals do (see handleCreateLocalTerminalImpl)
|
||||
// instead of 'root' or an inherited group SSH username.
|
||||
username: protocol === 'local' ? username : (resolvedAuth.username || 'root'),
|
||||
username: resolvedAuth.username || 'root',
|
||||
protocol,
|
||||
...getLogHostVisualSnapshot(effectiveHost),
|
||||
startTime: Date.now(),
|
||||
@@ -1197,13 +1186,8 @@ export function hasMultipleProtocolsImpl(getCtx: AppContextGetter, host: Host) {
|
||||
// Gates the protocol picker (legacy name kept for its existing wiring).
|
||||
// Only prompt when Telnet is available but isn't the host's default protocol;
|
||||
// SSH-only, SSH+Mosh and Telnet-default all connect directly.
|
||||
// SSH-only guard, matching VaultView's handleHostConnect: local-shell and
|
||||
// serial hosts must always launch their own transport, never offer
|
||||
// SSH/Telnet pickers driven by an inherited group Telnet flag. Hosts
|
||||
// without an explicit protocol are legacy SSH entries.
|
||||
const effective = resolveEffectiveHost(host);
|
||||
const effectiveProtocol = effective.protocol ?? 'ssh';
|
||||
return effectiveProtocol === 'ssh' && Boolean(effective.telnetEnabled);
|
||||
return Boolean(effective.telnetEnabled) && effective.protocol !== 'telnet';
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -34,17 +34,6 @@ test('serial targets use the serial editor', () => {
|
||||
);
|
||||
});
|
||||
|
||||
test('local targets use the local shell editor', () => {
|
||||
assert.equal(
|
||||
resolveWorkSurfaceHostEditorKind({
|
||||
mode: 'edit',
|
||||
openedHost: host({ protocol: 'local' }),
|
||||
requestId: 3,
|
||||
}),
|
||||
'local',
|
||||
);
|
||||
});
|
||||
|
||||
test('new and ssh targets use the standard editor', () => {
|
||||
assert.equal(
|
||||
resolveWorkSurfaceHostEditorKind({ mode: 'new', defaultGroup: null, requestId: 1 }),
|
||||
@@ -82,8 +71,8 @@ test('editor overlay leaves the work surface interactive outside the panel', ()
|
||||
|
||||
assert.match(source, /pointer-events-none absolute inset-0 z-40/);
|
||||
assert.match(source, /\[&>\*\]:pointer-events-auto/);
|
||||
assert.equal((source.match(/className="pointer-events-auto"/g) ?? []).length, 3);
|
||||
assert.equal((source.match(/layout="overlay"/g) ?? []).length, 3);
|
||||
assert.equal((source.match(/className="pointer-events-auto"/g) ?? []).length, 2);
|
||||
assert.equal((source.match(/layout="overlay"/g) ?? []).length, 2);
|
||||
});
|
||||
|
||||
test('editor host panels share vault resize width persistence', () => {
|
||||
@@ -92,7 +81,7 @@ test('editor host panels share vault resize width persistence', () => {
|
||||
assert.match(source, /STORAGE_KEY_VAULT_HOST_PANEL_WIDTH/);
|
||||
assert.match(source, /resizable:\s*true/);
|
||||
assert.match(source, /\{\.\.\.hostPanelResizeProps\}/);
|
||||
assert.equal((source.match(/\{\.\.\.hostPanelResizeProps\}/g) ?? []).length, 3);
|
||||
assert.equal((source.match(/\{\.\.\.hostPanelResizeProps\}/g) ?? []).length, 2);
|
||||
});
|
||||
|
||||
test('editor stays mounted while another app surface is active', () => {
|
||||
|
||||
@@ -5,7 +5,6 @@ import type { EditorTabChrome } from '../state/editorTabStore';
|
||||
import type { LogView } from '../state/logViewState';
|
||||
import { useI18n } from '../i18n/I18nProvider';
|
||||
import HostDetailsPanel from '../../components/HostDetailsPanel';
|
||||
import LocalShellHostDetailsPanel from '../../components/LocalShellHostDetailsPanel';
|
||||
import SerialHostDetailsPanel from '../../components/SerialHostDetailsPanel';
|
||||
import { PortalContainerProvider } from '../../components/ui/portal-container';
|
||||
import { resolveGroupDefaults } from '../../domain/groupConfig';
|
||||
@@ -23,16 +22,14 @@ import type {
|
||||
} from '../../types';
|
||||
import { useWorkSurfaceVisible } from './AppHostEditorSurface';
|
||||
|
||||
export type WorkSurfaceHostEditorKind = 'standard' | 'serial' | 'local';
|
||||
export type WorkSurfaceHostEditorKind = 'standard' | 'serial';
|
||||
|
||||
export function resolveWorkSurfaceHostEditorKind(
|
||||
target: WorkSurfaceHostEditorTarget,
|
||||
): WorkSurfaceHostEditorKind {
|
||||
return target.mode === 'edit' && target.openedHost.protocol === 'serial'
|
||||
? 'serial'
|
||||
: target.mode === 'edit' && target.openedHost.protocol === 'local'
|
||||
? 'local'
|
||||
: 'standard';
|
||||
: 'standard';
|
||||
}
|
||||
|
||||
function addGroupAndAncestors(groups: Set<string>, path: string | null | undefined) {
|
||||
@@ -163,18 +160,7 @@ export const AppHostEditorLayer: React.FC<AppHostEditorLayerProps> = ({
|
||||
style={getAppHostEditorLayerStyle(surfaceVisible)}
|
||||
>
|
||||
<PortalContainerProvider container={portalContainer}>
|
||||
{target.mode === 'edit' && target.openedHost.protocol === 'local' ? (
|
||||
<LocalShellHostDetailsPanel
|
||||
key={editorKey}
|
||||
initialData={target.openedHost}
|
||||
groups={groups}
|
||||
onSave={onSave}
|
||||
onCancel={onCancel}
|
||||
layout="overlay"
|
||||
className="pointer-events-auto"
|
||||
{...hostPanelResizeProps}
|
||||
/>
|
||||
) : target.mode === 'edit' && target.openedHost.protocol === 'serial' ? (
|
||||
{target.mode === 'edit' && target.openedHost.protocol === 'serial' ? (
|
||||
<SerialHostDetailsPanel
|
||||
key={editorKey}
|
||||
initialData={target.openedHost}
|
||||
|
||||
@@ -1,35 +0,0 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import test from 'node:test';
|
||||
import ts from 'typescript';
|
||||
import { createLocalShellHost, resolveHostDefaultLocalShell } from '../../domain/localShellHost';
|
||||
import { createHostTerminalSession } from '../state/sessionFactories';
|
||||
|
||||
// Exercise the production application callback, including its async command
|
||||
// resolution boundary, before the state hook snapshots the shell type.
|
||||
const source = readFileSync(new URL('./AppSideEffects.tsx', import.meta.url), 'utf8');
|
||||
const callback = source.match(/const handleRunSnippet = useCallback\(([\s\S]*?)\n {2}\);/)?.[1];
|
||||
assert.ok(callback);
|
||||
const compiled = ts.transpile(`const run = useCallback(${callback});`, { target: ts.ScriptTarget.ES2022 });
|
||||
|
||||
for (const script of [false, true]) {
|
||||
test(`saved default local shell is resolved before ${script ? 'script' : 'snippet'} sessions are created`, async () => {
|
||||
let session: ReturnType<typeof createHostTerminalSession> | undefined;
|
||||
const run = new Function('useCallback', 'isScriptSnippet', 'runSnippet', 'resolveSnippetCommand', 'resolveEffectiveHost', `${compiled} return run;`)(
|
||||
(value: unknown) => value,
|
||||
() => script,
|
||||
(_snippet: unknown, hosts: ReturnType<typeof createLocalShellHost>[]) => {
|
||||
session = createHostTerminalSession('snippet-local', hosts[0]);
|
||||
},
|
||||
async () => 'echo hello',
|
||||
(host: ReturnType<typeof createLocalShellHost>) => resolveHostDefaultLocalShell(host, {
|
||||
terminalSettings: { localShell: 'cmd' },
|
||||
resolveShellSetting: () => ({ command: 'cmd.exe' }),
|
||||
}),
|
||||
);
|
||||
await run({ id: 'snippet-1' }, [createLocalShellHost({ os: 'windows' })]);
|
||||
assert.equal(session?.protocol, 'local');
|
||||
assert.equal(session?.localShell, 'cmd.exe');
|
||||
assert.equal(session?.shellType, 'cmd');
|
||||
});
|
||||
}
|
||||
@@ -38,7 +38,6 @@ import {
|
||||
import { isTerminalBootEpochCurrent } from '../../domain/terminalBootEpoch';
|
||||
import { useI18n } from '../i18n/I18nProvider';
|
||||
import { matchesKeyBinding } from '../../domain/models';
|
||||
import { resolveHostDefaultLocalShell } from '../../domain/localShellHost';
|
||||
import { resolveGroupDefaults, applyGroupDefaults } from '../../domain/groupConfig';
|
||||
import { upsertKnownHost } from '../../domain/knownHosts';
|
||||
import { materializeHostProxyProfile } from '../../domain/proxyProfiles';
|
||||
@@ -54,7 +53,6 @@ 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';
|
||||
@@ -271,6 +269,22 @@ export function AppSideEffects() {
|
||||
renameSessionInline,
|
||||
} = sessionState;
|
||||
|
||||
// Presentation-field thrash stays off Host bags (Hosts use retainStable);
|
||||
// AppSideEffects only needs host/session maps for glue handlers and effects.
|
||||
const handleRunSnippet = useCallback(
|
||||
async (snippet: Snippet, targetHosts: Host[]) => {
|
||||
if (targetHosts.length === 0) return;
|
||||
if (isScriptSnippet(snippet)) {
|
||||
runSnippet(snippet, targetHosts);
|
||||
return;
|
||||
}
|
||||
const command = await resolveSnippetCommand(snippet);
|
||||
if (command === null) return;
|
||||
runSnippet(snippet, targetHosts, command);
|
||||
},
|
||||
[runSnippet],
|
||||
);
|
||||
|
||||
// Presence-only list: keystrokes must not rebuild App domains / TopTabs structure.
|
||||
const editorTabs = useEditorTabChromeList();
|
||||
|
||||
@@ -1352,32 +1366,8 @@ export function AppSideEffects() {
|
||||
{ validProxyProfileIds: proxyProfileIdSet },
|
||||
)
|
||||
: applyGroupDefaults(host, {}, { validProxyProfileIds: proxyProfileIdSet });
|
||||
const effectiveHost = materializeHostProxyProfile(withGroupDefaults, proxyProfiles);
|
||||
// Local-shell vault hosts saved with "System default shell" carry no
|
||||
// localShell; resolve the Settings → Terminal shell here at the shared
|
||||
// effective-host boundary so every launch path (connect, tray/dock open,
|
||||
// workspace creation/append, tunnels) uses the configured shell instead
|
||||
// of silently falling back to the backend OS default shell.
|
||||
return resolveHostDefaultLocalShell(effectiveHost, {
|
||||
discoveredShells,
|
||||
resolveShellSetting,
|
||||
terminalSettings,
|
||||
});
|
||||
}, [groupConfigs, proxyProfileIdSet, proxyProfiles, discoveredShells, terminalSettings]);
|
||||
|
||||
const handleRunSnippet = useCallback(
|
||||
async (snippet: Snippet, targetHosts: Host[]) => {
|
||||
if (targetHosts.length === 0) return;
|
||||
if (isScriptSnippet(snippet)) {
|
||||
runSnippet(snippet, targetHosts.map(resolveEffectiveHost));
|
||||
return;
|
||||
}
|
||||
const command = await resolveSnippetCommand(snippet);
|
||||
if (command === null) return;
|
||||
runSnippet(snippet, targetHosts.map(resolveEffectiveHost), command);
|
||||
},
|
||||
[runSnippet, resolveEffectiveHost],
|
||||
);
|
||||
return materializeHostProxyProfile(withGroupDefaults, proxyProfiles);
|
||||
}, [groupConfigs, proxyProfileIdSet, proxyProfiles]);
|
||||
|
||||
const createWorkspaceWithEffectiveHosts = useCallback((name: string, selectedHosts: Host[]) => {
|
||||
createWorkspaceWithHosts(name, selectedHosts.map(resolveEffectiveHost));
|
||||
@@ -1408,17 +1398,14 @@ export function AppSideEffects() {
|
||||
return handleConnectToHostImpl(() => ({
|
||||
addConnectionLog,
|
||||
connectToHost,
|
||||
discoveredShells,
|
||||
host,
|
||||
identities,
|
||||
keys,
|
||||
resolveEffectiveHost: effectiveHostResolver,
|
||||
resolveHostAuth,
|
||||
resolveShellSetting,
|
||||
systemInfoRef,
|
||||
terminalSettings,
|
||||
}), host, hidden);
|
||||
}, [addConnectionLog, connectToHost, resolveEffectiveHost, identities, keys, discoveredShells, terminalSettings]);
|
||||
}, [addConnectionLog, connectToHost, resolveEffectiveHost, identities, keys]);
|
||||
|
||||
const handleConnectToHostWithTabName = useCallback((host: Host, tabName?: string) => {
|
||||
const sessionId = handleConnectToHost(host);
|
||||
@@ -1445,10 +1432,6 @@ 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.` };
|
||||
@@ -1491,7 +1474,6 @@ export function AppSideEffects() {
|
||||
stopTunnel,
|
||||
stopRuleTunnels,
|
||||
openHost: openHostForVaultAgent,
|
||||
findExistingSessionForHost: findExistingSessionForHostForVaultAgent,
|
||||
closeSession: closeSessionForVaultAgent,
|
||||
getScriptSessionMeta: (sessionId) => sessions.find((session) => session.id === sessionId),
|
||||
});
|
||||
|
||||
@@ -37,7 +37,6 @@ import { useExternalMcpToggleState } from '../state/useExternalMcpToggleState';
|
||||
import { selectPluginThemeTokens } from '../state/pluginContributionEnvironment';
|
||||
import { netcattyBridge } from '../../infrastructure/services/netcattyBridge';
|
||||
import { resolveEffectiveTerminalHost } from '../../domain/terminalHostResolution';
|
||||
import { useDefaultLocalShellContext } from '../../lib/useDiscoveredShells';
|
||||
import { getAvailablePaneMagnificationController } from '../../domain/paneMagnification';
|
||||
import { pluginViewTabStore, usePluginViewTabs } from '../state/pluginViewTabStore';
|
||||
import { buildPluginSettingScopeCatalog } from '../state/usePluginSettingScopeCatalog';
|
||||
@@ -355,16 +354,14 @@ function AppViewInner({ domains }: AppViewProps) {
|
||||
[proxyProfiles],
|
||||
);
|
||||
|
||||
const defaultLocalShell = useDefaultLocalShellContext(terminalSettings);
|
||||
const resolveWorkspaceAppendHost = useCallback((host: typeof hosts[number]) => (
|
||||
resolveEffectiveTerminalHost({
|
||||
host,
|
||||
groupConfigs,
|
||||
proxyProfiles,
|
||||
validProxyProfileIds,
|
||||
defaultLocalShell,
|
||||
})
|
||||
), [groupConfigs, proxyProfiles, validProxyProfileIds, defaultLocalShell]);
|
||||
), [groupConfigs, proxyProfiles, validProxyProfileIds]);
|
||||
|
||||
const handleAppendHostToWorkspace = useCallback((workspaceId: string, hostId: string) => {
|
||||
const host = hosts.find((entry) => entry.id === hostId);
|
||||
|
||||
@@ -522,8 +522,6 @@ export const enAiMessages: Messages = {
|
||||
'ai.chat.hoursAgo': '{n}h ago',
|
||||
'ai.chat.daysAgo': '{n}d ago',
|
||||
'ai.chat.newChat': 'New Chat',
|
||||
'ai.chat.undoLastTurn': 'Undo last turn',
|
||||
'ai.chat.undoLastTurnNotice': "Original chat kept. Commands and file changes are not undone. Return to the original chat for saved full outputs and existing background tasks.",
|
||||
'ai.chat.allSessions': 'All Sessions',
|
||||
'ai.chat.loadEarlierMessages': 'Load earlier messages ({n} more)',
|
||||
'ai.chat.jumpNav': 'Jump to message',
|
||||
|
||||
@@ -1123,7 +1123,6 @@ Highlight the focused split pane:
|
||||
'vault.tree.expandAll': 'Expand All',
|
||||
'vault.tree.collapseAll': 'Collapse All',
|
||||
'vault.hosts.newHost': 'New Host',
|
||||
'vault.hosts.newLocalShell': 'Local Shell (CMD)',
|
||||
'vault.hosts.newGroup': 'New Group',
|
||||
'vault.hosts.import': 'Import',
|
||||
'vault.hosts.export': 'Export',
|
||||
|
||||
@@ -1018,21 +1018,6 @@ export const enVaultMessages: Messages = {
|
||||
'hostDetails.addTag': 'Add a tag...',
|
||||
'hostDetails.createTag': 'Create tag',
|
||||
'hostDetails.createGroup': 'Create group',
|
||||
// Local shell (CMD) host entries
|
||||
'localShell.subtitle': 'Local Shell',
|
||||
'localShell.panel.title': 'New Local Shell',
|
||||
'localShell.panel.title.edit': 'Edit Local Shell',
|
||||
'localShell.field.label': 'Name',
|
||||
'localShell.field.labelPlaceholder': 'e.g. Project A · CMD',
|
||||
'localShell.field.shell': 'Shell',
|
||||
'localShell.field.shellDefault': 'System default shell',
|
||||
'localShell.field.shellDefaultDesc': 'Leave empty to use the default local shell configured in Settings → Terminal.',
|
||||
'localShell.field.shellEmpty': 'No shells found',
|
||||
'localShell.field.shellResolved': 'Runs {command}',
|
||||
'localShell.field.startDir': 'Start directory',
|
||||
'localShell.field.startDirPlaceholder': 'Inherit global start directory (home if unset)',
|
||||
'localShell.saveAndConnect': 'Save & Connect',
|
||||
|
||||
'hostDetails.plugin.title': 'Plugin connection',
|
||||
'hostDetails.plugin.provider.placeholder': 'Select a connection provider',
|
||||
'hostDetails.plugin.provider.empty': 'No connection providers are installed',
|
||||
|
||||
@@ -513,8 +513,6 @@ export const esAiMessages: Messages = {
|
||||
'ai.chat.hoursAgo': 'hace {n} h',
|
||||
'ai.chat.daysAgo': 'hace {n} d',
|
||||
'ai.chat.newChat': 'Nuevo chat',
|
||||
'ai.chat.undoLastTurn': 'Deshacer el último turno',
|
||||
'ai.chat.undoLastTurnNotice': "Se conserva el chat original. Los comandos y cambios de archivos no se deshacen. Consulta allí las salidas completas guardadas y las tareas en segundo plano existentes.",
|
||||
'ai.chat.allSessions': 'Todas las sesiones',
|
||||
'ai.chat.loadEarlierMessages': 'Cargar mensajes anteriores ({n} más)',
|
||||
'ai.chat.jumpNav': 'Ir al mensaje',
|
||||
|
||||
@@ -1081,7 +1081,6 @@ Resalta el panel dividido enfocado:
|
||||
'vault.tree.expandAll': 'Expandir todo',
|
||||
'vault.tree.collapseAll': 'Contraer todo',
|
||||
'vault.hosts.newHost': 'Nuevo host',
|
||||
'vault.hosts.newLocalShell': 'Shell local (CMD)',
|
||||
'vault.hosts.newGroup': 'Nuevo grupo',
|
||||
'vault.hosts.import': 'Importar',
|
||||
'vault.hosts.export': 'Exportar',
|
||||
|
||||
@@ -1008,21 +1008,6 @@ export const esVaultMessages: Messages = {
|
||||
'hostDetails.addTag': 'Agregar una etiqueta...',
|
||||
'hostDetails.createTag': 'Crear etiqueta',
|
||||
'hostDetails.createGroup': 'Crear grupo',
|
||||
// Entradas de shell local (CMD) en la bóveda
|
||||
'localShell.subtitle': 'Shell local',
|
||||
'localShell.panel.title': 'Nueva shell local',
|
||||
'localShell.panel.title.edit': 'Editar shell local',
|
||||
'localShell.field.label': 'Nombre',
|
||||
'localShell.field.labelPlaceholder': 'p. ej. Proyecto A · CMD',
|
||||
'localShell.field.shell': 'Shell',
|
||||
'localShell.field.shellDefault': 'Shell predeterminada del sistema',
|
||||
'localShell.field.shellDefaultDesc': 'Si se deja vacío, se usa la shell local predeterminada configurada en Ajustes → Terminal.',
|
||||
'localShell.field.shellEmpty': 'No se encontraron shells',
|
||||
'localShell.field.shellResolved': 'Ejecuta {command}',
|
||||
'localShell.field.startDir': 'Directorio de inicio',
|
||||
'localShell.field.startDirPlaceholder': 'Heredar directorio inicial global (personal si no se configura)',
|
||||
'localShell.saveAndConnect': 'Guardar y conectar',
|
||||
|
||||
'hostDetails.plugin.title': 'Conexión de plugin',
|
||||
'hostDetails.plugin.provider.placeholder': 'Selecciona un proveedor de conexión',
|
||||
'hostDetails.plugin.provider.empty': 'No hay proveedores de conexión instalados',
|
||||
|
||||
@@ -469,8 +469,6 @@ export const ruAiMessages: Messages = {
|
||||
'ai.chat.hoursAgo': '{n}ч назад',
|
||||
'ai.chat.daysAgo': '{n}д назад',
|
||||
'ai.chat.newChat': 'Новый чат',
|
||||
'ai.chat.undoLastTurn': 'Отменить последний ход',
|
||||
'ai.chat.undoLastTurnNotice': "Исходный чат сохранён. Команды и изменения файлов не отменяются. Полные сохранённые результаты и фоновые задачи доступны в исходном чате.",
|
||||
'ai.chat.allSessions': 'Все сессии',
|
||||
'ai.chat.loadEarlierMessages': 'Загрузить более ранние сообщения (ещё {n})',
|
||||
'ai.chat.jumpNav': 'Перейти к сообщению',
|
||||
|
||||
@@ -102,7 +102,6 @@ export const ruVaultMessages: Messages = {
|
||||
'vault.tree.expandAll': 'Развернуть все',
|
||||
'vault.tree.collapseAll': 'Свернуть все',
|
||||
'vault.hosts.newHost': 'Новый хост',
|
||||
'vault.hosts.newLocalShell': 'Локальная оболочка (CMD)',
|
||||
'vault.hosts.newGroup': 'Новая группа',
|
||||
'vault.hosts.import': 'Импорт',
|
||||
'vault.hosts.export': 'Экспорт',
|
||||
@@ -1052,21 +1051,6 @@ export const ruVaultMessages: Messages = {
|
||||
'hostDetails.addTag': 'Добавить тег...',
|
||||
'hostDetails.createTag': 'Создать тег',
|
||||
'hostDetails.createGroup': 'Создать группу',
|
||||
// Записи локальной оболочки (CMD) в хранилище
|
||||
'localShell.subtitle': 'Локальная оболочка',
|
||||
'localShell.panel.title': 'Новая локальная оболочка',
|
||||
'localShell.panel.title.edit': 'Изменить локальную оболочку',
|
||||
'localShell.field.label': 'Название',
|
||||
'localShell.field.labelPlaceholder': 'напр. Проект A · CMD',
|
||||
'localShell.field.shell': 'Оболочка',
|
||||
'localShell.field.shellDefault': 'Системная оболочка по умолчанию',
|
||||
'localShell.field.shellDefaultDesc': 'Если оставить пустым, используется локальная оболочка по умолчанию из Настройки → Терминал.',
|
||||
'localShell.field.shellEmpty': 'Оболочки не найдены',
|
||||
'localShell.field.shellResolved': 'Запускает {command}',
|
||||
'localShell.field.startDir': 'Начальный каталог',
|
||||
'localShell.field.startDirPlaceholder': 'Использовать общий начальный каталог (иначе домашний)',
|
||||
'localShell.saveAndConnect': 'Сохранить и открыть',
|
||||
|
||||
'hostDetails.plugin.title': 'Подключение плагина',
|
||||
'hostDetails.plugin.provider.placeholder': 'Выберите провайдер подключения',
|
||||
'hostDetails.plugin.provider.empty': 'Провайдеры подключения не установлены',
|
||||
|
||||
@@ -1,20 +0,0 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import en from './en.ts';
|
||||
import ru from './ru.ts';
|
||||
import es from './es.ts';
|
||||
import zhCN from './zh-CN.ts';
|
||||
import zhTW from './zh-TW.ts';
|
||||
|
||||
const UNDO_KEYS = [
|
||||
'ai.chat.undoLastTurn',
|
||||
'ai.chat.undoLastTurnNotice',
|
||||
] as const;
|
||||
|
||||
test('Undo-last-turn UI is localized in every supported locale', () => {
|
||||
for (const [name, messages] of Object.entries({ en, es, 'zh-CN': zhCN, 'zh-TW': zhTW, ru })) {
|
||||
const missing = UNDO_KEYS.filter(key => !messages[key]);
|
||||
assert.deepEqual(missing, [], `${name} is missing undo-last-turn labels`);
|
||||
}
|
||||
});
|
||||
@@ -522,8 +522,6 @@ export const zhCNAiMessages: Messages = {
|
||||
'ai.chat.hoursAgo': '{n}小时前',
|
||||
'ai.chat.daysAgo': '{n}天前',
|
||||
'ai.chat.newChat': '新对话',
|
||||
'ai.chat.undoLastTurn': '撤销上一轮',
|
||||
'ai.chat.undoLastTurnNotice': "原对话已保留。已执行的命令和文件修改不会撤销;之前保存的完整输出和后台任务,请回原对话查看。",
|
||||
'ai.chat.allSessions': '所有会话',
|
||||
'ai.chat.loadEarlierMessages': '加载更早的消息(还有 {n} 条)',
|
||||
'ai.chat.jumpNav': '跳转到消息',
|
||||
|
||||
@@ -740,7 +740,6 @@ export const zhCNCoreMessages: Messages = {
|
||||
'vault.tree.expandAll': '展开全部',
|
||||
'vault.tree.collapseAll': '折叠全部',
|
||||
'vault.hosts.newHost': '新建主机',
|
||||
'vault.hosts.newLocalShell': '本地终端 (CMD)',
|
||||
'vault.hosts.newGroup': '新建分组',
|
||||
'vault.hosts.import': '导入',
|
||||
'vault.hosts.export': '导出',
|
||||
|
||||
@@ -390,21 +390,6 @@ export const zhCNVaultMessages: Messages = {
|
||||
'hostDetails.addTag': '添加标签...',
|
||||
'hostDetails.createTag': '创建标签',
|
||||
'hostDetails.createGroup': '创建分组',
|
||||
// 本地终端(CMD)主机条目
|
||||
'localShell.subtitle': '本地终端',
|
||||
'localShell.panel.title': '新建本地终端',
|
||||
'localShell.panel.title.edit': '编辑本地终端',
|
||||
'localShell.field.label': '名称',
|
||||
'localShell.field.labelPlaceholder': '例如:项目 A · CMD',
|
||||
'localShell.field.shell': 'Shell',
|
||||
'localShell.field.shellDefault': '系统默认 Shell',
|
||||
'localShell.field.shellDefaultDesc': '留空则使用设置 → 终端中配置的默认本地 Shell。',
|
||||
'localShell.field.shellEmpty': '未发现可用 Shell',
|
||||
'localShell.field.shellResolved': '启动 {command}',
|
||||
'localShell.field.startDir': '启动目录',
|
||||
'localShell.field.startDirPlaceholder': '继承全局起始目录(未设置时为主目录)',
|
||||
'localShell.saveAndConnect': '保存并打开',
|
||||
|
||||
'hostDetails.plugin.title': '插件连接',
|
||||
'hostDetails.plugin.provider.placeholder': '选择连接提供程序',
|
||||
'hostDetails.plugin.provider.empty': '未安装连接提供程序',
|
||||
|
||||
@@ -512,8 +512,6 @@ export const zhTWAiMessages: Messages = {
|
||||
'ai.chat.hoursAgo': '{n}小時前',
|
||||
'ai.chat.daysAgo': '{n}天前',
|
||||
'ai.chat.newChat': '新對話',
|
||||
'ai.chat.undoLastTurn': '復原上一輪',
|
||||
'ai.chat.undoLastTurnNotice': "原對話已保留。已執行的命令和檔案修改不會復原;之前儲存的完整輸出和背景工作,請回原對話查看。",
|
||||
'ai.chat.allSessions': '所有工作階段',
|
||||
'ai.chat.loadEarlierMessages': '載入更早的訊息(還有 {n} 條)',
|
||||
'ai.chat.jumpNav': '跳轉到訊息',
|
||||
|
||||
@@ -742,7 +742,6 @@ export const zhTWCoreMessages: Messages = {
|
||||
'vault.tree.expandAll': '展開全部',
|
||||
'vault.tree.collapseAll': '摺疊全部',
|
||||
'vault.hosts.newHost': '新增主機',
|
||||
'vault.hosts.newLocalShell': '本地終端 (CMD)',
|
||||
'vault.hosts.newGroup': '新增群組',
|
||||
'vault.hosts.import': '匯入',
|
||||
'vault.hosts.export': '匯出',
|
||||
|
||||
@@ -390,21 +390,6 @@ export const zhTWVaultMessages: Messages = {
|
||||
'hostDetails.addTag': '新增標籤...',
|
||||
'hostDetails.createTag': '建立標籤',
|
||||
'hostDetails.createGroup': '建立群組',
|
||||
// 本地終端(CMD)主機項目
|
||||
'localShell.subtitle': '本地終端',
|
||||
'localShell.panel.title': '新增本地終端',
|
||||
'localShell.panel.title.edit': '編輯本地終端',
|
||||
'localShell.field.label': '名稱',
|
||||
'localShell.field.labelPlaceholder': '例如:專案 A · CMD',
|
||||
'localShell.field.shell': 'Shell',
|
||||
'localShell.field.shellDefault': '系統預設 Shell',
|
||||
'localShell.field.shellDefaultDesc': '留空則使用設定 → 終端中設定的預設本地 Shell。',
|
||||
'localShell.field.shellEmpty': '未找到可用的 Shell',
|
||||
'localShell.field.shellResolved': '啟動 {command}',
|
||||
'localShell.field.startDir': '啟動目錄',
|
||||
'localShell.field.startDirPlaceholder': '繼承全域起始目錄(未設定時為主目錄)',
|
||||
'localShell.saveAndConnect': '儲存並開啟',
|
||||
|
||||
'hostDetails.plugin.title': '外掛程式連線',
|
||||
'hostDetails.plugin.provider.placeholder': '選擇連線提供者',
|
||||
'hostDetails.plugin.provider.empty': '尚未安裝連線提供者',
|
||||
|
||||
@@ -1,244 +0,0 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import { createHostTerminalSession } from "./sessionFactories.ts";
|
||||
import { createLocalTerminalSession } from "./sessionFactories.ts";
|
||||
import { resolveHostDefaultLocalShell } from "../../domain/localShellHost";
|
||||
import { resolveTerminalSessionHost } from "../../domain/terminalHostResolution";
|
||||
import type { Host } from "../../domain/models";
|
||||
|
||||
const localVaultHost = (overrides: Partial<Host> = {}): Host => ({
|
||||
id: "local-vault-1",
|
||||
label: "Project A CMD",
|
||||
hostname: "localhost",
|
||||
username: "",
|
||||
group: "Projects/A",
|
||||
tags: ["local"],
|
||||
os: "linux",
|
||||
protocol: "local",
|
||||
createdAt: 0,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
test("createHostTerminalSession copies local shell fields for local vault hosts", () => {
|
||||
const host = localVaultHost({
|
||||
localShell: "wsl-ubuntu",
|
||||
localShellArgs: ["--cd", "/work"],
|
||||
localShellName: "Ubuntu (WSL)",
|
||||
localShellIcon: "ubuntu",
|
||||
localStartDir: "/work",
|
||||
});
|
||||
const session = createHostTerminalSession("session-1", host);
|
||||
|
||||
assert.equal(session.protocol, "local");
|
||||
assert.equal(session.hostId, "local-vault-1");
|
||||
assert.equal(session.localShell, "wsl-ubuntu");
|
||||
assert.deepEqual(session.localShellArgs, ["--cd", "/work"]);
|
||||
assert.equal(session.localShellName, "Ubuntu (WSL)");
|
||||
assert.equal(session.localShellIcon, "ubuntu");
|
||||
assert.equal(session.localStartDir, "/work");
|
||||
});
|
||||
|
||||
test("createHostTerminalSession does not add local shell fields to SSH hosts", () => {
|
||||
const host: Host = {
|
||||
...localVaultHost({ protocol: "ssh", hostname: "example.com", port: 22 }),
|
||||
} as Host;
|
||||
const session = createHostTerminalSession("session-2", host);
|
||||
|
||||
assert.equal(session.protocol, "ssh");
|
||||
assert.equal(session.localShell, undefined);
|
||||
assert.equal(session.localShellName, undefined);
|
||||
assert.equal(session.shellType, undefined);
|
||||
});
|
||||
|
||||
test("createHostTerminalSession classifies the shell for CMD/PowerShell local vault hosts", () => {
|
||||
const cmd = createHostTerminalSession("session-cmd", localVaultHost({ localShell: "cmd", os: "windows" }));
|
||||
assert.equal(cmd.shellType, "cmd");
|
||||
|
||||
const pwsh = createHostTerminalSession("session-pwsh", localVaultHost({ localShell: "pwsh", os: "windows" }));
|
||||
assert.equal(pwsh.shellType, "powershell");
|
||||
|
||||
// Discovered shell IDs persisted by the local-shell editor are not
|
||||
// executable basenames, but they still resolve to POSIX shells at spawn
|
||||
// time — WSL distros, Git Bash, and Cygwin all run a POSIX shell.
|
||||
const wslId = createHostTerminalSession("session-wsl-id", localVaultHost({ localShell: "wsl-ubuntu", os: "windows" }));
|
||||
assert.equal(wslId.shellType, "posix");
|
||||
|
||||
const gitBash = createHostTerminalSession("session-git-bash", localVaultHost({ localShell: "git-bash", os: "windows" }));
|
||||
assert.equal(gitBash.shellType, "posix");
|
||||
|
||||
const cygwin = createHostTerminalSession("session-cygwin", localVaultHost({ localShell: "cygwin", os: "windows" }));
|
||||
assert.equal(cygwin.shellType, "posix");
|
||||
|
||||
// Unix discovery emits disambiguated path-slug IDs when multiple
|
||||
// installations share a basename (e.g. /bin/bash vs
|
||||
// /opt/homebrew/bin/bash); the backend launches the resolved command, so
|
||||
// the session must classify as that command's shell type, not "unknown".
|
||||
const slugBash = createHostTerminalSession("session-slug-bash", localVaultHost({ localShell: "bin-bash", os: "macos" }));
|
||||
assert.equal(slugBash.shellType, "posix");
|
||||
|
||||
const slugHomebrewBash = createHostTerminalSession(
|
||||
"session-slug-homebrew",
|
||||
localVaultHost({ localShell: "opt-homebrew-bin-bash", os: "macos" }),
|
||||
);
|
||||
assert.equal(slugHomebrewBash.shellType, "posix");
|
||||
|
||||
const customPowershellPath = createHostTerminalSession(
|
||||
"session-pwsh-path",
|
||||
localVaultHost({ localShell: "C:\\Program Files\\PowerShell\\7\\pwsh.exe", os: "windows" }),
|
||||
);
|
||||
assert.equal(customPowershellPath.shellType, "powershell");
|
||||
|
||||
// Default-shell hosts (empty localShell) launch the machine's OS default
|
||||
// shell (the backend's own detection), so classification must use the
|
||||
// runtime platform — not the saved host.os, which may describe a machine
|
||||
// the host was synced/imported from. Node 22's navigator.userAgent is
|
||||
// always "Node.js/<version>", so this test environment always classifies
|
||||
// as POSIX regardless of the CI runner's real OS.
|
||||
const windowsSyncedDefault = createHostTerminalSession("session-win-default", localVaultHost({ os: "windows" }));
|
||||
assert.equal(windowsSyncedDefault.shellType, "posix");
|
||||
|
||||
const posixDefault = createHostTerminalSession("session-posix-default", localVaultHost({ os: "linux" }));
|
||||
assert.equal(posixDefault.shellType, "posix");
|
||||
|
||||
const unknownCustom = createHostTerminalSession(
|
||||
"session-unknown",
|
||||
localVaultHost({ localShell: "/usr/local/bin/exotic-shell", os: "linux" }),
|
||||
);
|
||||
assert.equal(unknownCustom.shellType, "unknown");
|
||||
});
|
||||
|
||||
test("createLocalTerminalSession is unaffected by vault local hosts", () => {
|
||||
const session = createLocalTerminalSession("session-3", { shellName: "CMD" });
|
||||
assert.equal(session.hostId, "local-terminal");
|
||||
assert.equal(session.localShellName, "CMD");
|
||||
});
|
||||
|
||||
test("createHostTerminalSession snapshots an explicit no-args local shell as an empty array", () => {
|
||||
// Legacy vault hosts can predate localShellArgs, so the field may be
|
||||
// absent on the Host; the session snapshot must still distinguish
|
||||
// "launched with no args" ([]) from "no metadata" (undefined).
|
||||
const host = localVaultHost({
|
||||
localShell: "cmd",
|
||||
localShellName: "Command Prompt",
|
||||
os: "windows",
|
||||
});
|
||||
const session = createHostTerminalSession("session-noargs", host);
|
||||
|
||||
assert.ok(Array.isArray(session.localShellArgs), "args snapshot must be present");
|
||||
assert.deepEqual(session.localShellArgs, []);
|
||||
});
|
||||
|
||||
test("resolveTerminalSessionHost does not merge edited host args into a no-args local snapshot", () => {
|
||||
const launchTimeHost = localVaultHost({
|
||||
localShell: "cmd",
|
||||
localShellName: "Command Prompt",
|
||||
os: "windows",
|
||||
});
|
||||
const session = createHostTerminalSession("session-1", launchTimeHost);
|
||||
|
||||
// The saved host was later edited to a shell with args; the snapshot taken
|
||||
// at launch time must win so the clone relaunches CMD, not WSL.
|
||||
const editedHost: Host = {
|
||||
...launchTimeHost,
|
||||
label: "Edited WSL",
|
||||
localShell: "wsl-ubuntu",
|
||||
localShellArgs: ["--cd", "/elsewhere"],
|
||||
localShellName: "Ubuntu (WSL)",
|
||||
localStartDir: "/elsewhere",
|
||||
};
|
||||
|
||||
const resolved = resolveTerminalSessionHost({
|
||||
session,
|
||||
hosts: [editedHost],
|
||||
groupConfigs: [],
|
||||
proxyProfiles: [],
|
||||
localOs: "windows",
|
||||
});
|
||||
|
||||
assert.equal(resolved.localShell, "cmd");
|
||||
assert.deepEqual(resolved.localShellArgs, []);
|
||||
assert.equal(resolved.localShellName, "Command Prompt");
|
||||
});
|
||||
|
||||
test("createHostTerminalSession snapshots an inherited start directory for Copy Tab", () => {
|
||||
// The saved host has no directory of its own, so the starter launches in
|
||||
// Settings > Terminal's localStartDir. That effective directory has to be
|
||||
// recorded now; overlay falls back to the host or the current setting when
|
||||
// the session field is missing.
|
||||
const saved = localVaultHost({
|
||||
localShell: "bash",
|
||||
localShellName: "Bash",
|
||||
localStartDir: "",
|
||||
});
|
||||
const effective = resolveHostDefaultLocalShell(saved, {
|
||||
terminalSettings: { localStartDir: "/home/alice/global" },
|
||||
});
|
||||
const session = createHostTerminalSession("session-inherit", effective);
|
||||
assert.equal(session.localStartDir, "/home/alice/global");
|
||||
|
||||
const changedSettings = {
|
||||
terminalSettings: { localShell: "fish", localStartDir: "/home/alice/changed" },
|
||||
};
|
||||
const afterSettingsChange = resolveTerminalSessionHost({
|
||||
session,
|
||||
hosts: [saved],
|
||||
groupConfigs: [],
|
||||
proxyProfiles: [],
|
||||
localOs: "linux",
|
||||
defaultLocalShell: changedSettings,
|
||||
});
|
||||
assert.equal(afterSettingsChange.localStartDir, "/home/alice/global");
|
||||
|
||||
const afterHostEdit = resolveTerminalSessionHost({
|
||||
session,
|
||||
hosts: [{ ...saved, localStartDir: "/home/alice/edited" }],
|
||||
groupConfigs: [],
|
||||
proxyProfiles: [],
|
||||
localOs: "linux",
|
||||
defaultLocalShell: changedSettings,
|
||||
});
|
||||
assert.equal(afterHostEdit.localStartDir, "/home/alice/global");
|
||||
});
|
||||
|
||||
test("createHostTerminalSession snapshots an inherited start directory for a default shell host", () => {
|
||||
const saved = localVaultHost();
|
||||
const effective = resolveHostDefaultLocalShell(saved, {
|
||||
terminalSettings: { localShell: "bash", localStartDir: "/home/alice/global" },
|
||||
resolveShellSetting: () => ({ command: "/bin/bash", args: ["-l"] }),
|
||||
});
|
||||
const session = createHostTerminalSession("session-default-shell", effective);
|
||||
|
||||
assert.equal(session.localShell, "/bin/bash");
|
||||
assert.deepEqual(session.localShellArgs, ["-l"]);
|
||||
assert.equal(session.localStartDir, "/home/alice/global");
|
||||
});
|
||||
|
||||
test("resolveHostDefaultLocalShell keeps an explicit start directory and ignores non-local hosts", () => {
|
||||
const explicit = localVaultHost({ localShell: "bash", localStartDir: "/work" });
|
||||
const resolvedExplicit = resolveHostDefaultLocalShell(explicit, {
|
||||
terminalSettings: { localStartDir: "/home/alice/global" },
|
||||
});
|
||||
assert.equal(resolvedExplicit.localStartDir, "/work");
|
||||
assert.equal(createHostTerminalSession("session-explicit", resolvedExplicit).localStartDir, "/work");
|
||||
|
||||
const ssh = localVaultHost({ protocol: "ssh", hostname: "example.com", port: 22 });
|
||||
const resolvedSsh = resolveHostDefaultLocalShell(ssh, {
|
||||
terminalSettings: { localStartDir: "/home/alice/global" },
|
||||
});
|
||||
assert.equal(resolvedSsh.localStartDir, undefined);
|
||||
assert.equal(resolvedSsh, ssh);
|
||||
});
|
||||
|
||||
test("saved local sessions use the ordinary local username marker without changing vault or SSH credentials", () => {
|
||||
const saved = localVaultHost({ username: "" });
|
||||
const ordinary = createLocalTerminalSession("ordinary-local");
|
||||
const opened = createHostTerminalSession("saved-local", saved);
|
||||
assert.equal(opened.username, ordinary.username);
|
||||
assert.equal(opened.username, "local");
|
||||
assert.equal(saved.username, "");
|
||||
const remote = { ...saved, protocol: "ssh" as const, username: "remote-user" };
|
||||
assert.equal(createHostTerminalSession("ssh-session", remote).username, "remote-user");
|
||||
const resolved = resolveTerminalSessionHost({ session: opened, hosts: [saved], groupConfigs: [], proxyProfiles: [], localOs: "linux" });
|
||||
assert.equal(resolved.username, ordinary.username, "terminal-run scripts receive the same local marker");
|
||||
});
|
||||
@@ -4,7 +4,7 @@ import test from "node:test";
|
||||
import type { Host } from "../../domain/models";
|
||||
import { prepareSerialConfigForSavedHost } from "../../domain/serialBackspace";
|
||||
import { buildTelnetDeepLinkConnectionHost } from "../../domain/telnetDeepLink";
|
||||
import { resolveEffectiveTerminalHost, resolveTerminalSessionHost } from "../../domain/terminalHostResolution";
|
||||
import { resolveEffectiveTerminalHost } from "../../domain/terminalHostResolution";
|
||||
import {
|
||||
createHostTerminalSession,
|
||||
createSerialTerminalSession,
|
||||
@@ -171,16 +171,3 @@ test("host session factories snapshot plugin connection configuration", () => {
|
||||
assert.deepEqual(workspace.pluginConnection, pluginConnection);
|
||||
assert.notEqual(workspace.pluginConnection, pluginConnection);
|
||||
});
|
||||
|
||||
test("local sessions preserve intentionally absent display metadata when the saved shell changes", () => {
|
||||
const original = host({ protocol: "local", hostname: "localhost", localShell: "/bin/bash" });
|
||||
const source = createHostTerminalSession("original-bash", original);
|
||||
const edited = { ...original, localShell: "/bin/zsh", localShellName: "Zsh", localShellIcon: "zsh" };
|
||||
const copied = resolveTerminalSessionHost({ session: source, hosts: [edited], groupConfigs: [], proxyProfiles: [], localOs: "macos" });
|
||||
assert.equal(copied.localShell, "/bin/bash");
|
||||
assert.equal(copied.localShellName, "");
|
||||
assert.equal(copied.localShellIcon, "");
|
||||
const named = createHostTerminalSession("named-zsh", edited);
|
||||
assert.equal(named.localShellName, "Zsh");
|
||||
assert.equal(named.localShellIcon, "zsh");
|
||||
});
|
||||
@@ -1,5 +1,4 @@
|
||||
import type { Host, SerialConfig, TerminalSession } from "../../domain/models";
|
||||
import { classifyLocalShellType } from "../../lib/localShell";
|
||||
|
||||
export interface LocalTerminalOptions {
|
||||
shellType?: TerminalSession["shellType"];
|
||||
@@ -19,28 +18,6 @@ export interface LocalTerminalOptions {
|
||||
*/
|
||||
export const LOCAL_TERMINAL_HOST_ID = "local-terminal";
|
||||
|
||||
/**
|
||||
* [] captures the absence of custom arguments instead of inheriting them
|
||||
* from a later host edit. The starter still applies backend defaults to []
|
||||
* and undefined alike. Share the array to preserve pane equality checks.
|
||||
*/
|
||||
const EMPTY_LOCAL_SHELL_ARGS: string[] = [];
|
||||
|
||||
/**
|
||||
* Platform string describing the machine that is launching the session.
|
||||
*
|
||||
* Vault local hosts can be synced/imported from another OS, so their saved
|
||||
* `host.os` describes the source machine. When the host has no explicit
|
||||
* shell (`localShell` empty), `startLocalSession` spawns the current
|
||||
* machine's OS default shell, so classification must fall back to the
|
||||
* runtime platform — not the persisted host OS — or a Windows host opened
|
||||
* on Linux would be tagged "powershell" while Bash actually runs.
|
||||
*/
|
||||
const runtimePlatformLike = (): string | undefined => {
|
||||
if (typeof navigator === "undefined") return undefined;
|
||||
return navigator.userAgent || navigator.platform || undefined;
|
||||
};
|
||||
|
||||
export const createLocalTerminalSession = (
|
||||
sessionId: string,
|
||||
options?: LocalTerminalOptions,
|
||||
@@ -136,30 +113,6 @@ export const createHostTerminalSession = (
|
||||
moshEnabled: host.moshEnabled,
|
||||
etEnabled: host.etEnabled,
|
||||
charset: host.charset,
|
||||
// Local-shell vault hosts carry the per-host shell selection into the
|
||||
// session (the bridge resolves discovered shell ids at spawn time).
|
||||
// shellType matches ordinary local-terminal creation so captured-CWD
|
||||
// restore can reject POSIX injection into CMD/PowerShell sessions.
|
||||
...(host.protocol === "local" ? {
|
||||
username: "local",
|
||||
// With an explicit shell the basename classifies it on any OS, so the
|
||||
// saved host.os stays the fallback; a default-shell host launches the
|
||||
// current machine's OS default, so classify against the runtime.
|
||||
shellType: classifyLocalShellType(
|
||||
host.localShell,
|
||||
host.localShell?.trim() ? host.os : runtimePlatformLike(),
|
||||
),
|
||||
localShell: host.localShell ?? "",
|
||||
// Preserve field presence: an edited host must not contribute its args
|
||||
// to a session that was launched with an explicit shell and no args.
|
||||
localShellArgs: host.localShellArgs ?? EMPTY_LOCAL_SHELL_ARGS,
|
||||
localShellName: host.localShellName ?? "",
|
||||
localShellIcon: host.localShellIcon ?? "",
|
||||
// Effective directory from resolveHostDefaultLocalShell, including a
|
||||
// Settings directory the saved host inherits. Copy Tab overlays this
|
||||
// snapshot, so a later settings or vault edit cannot move the relaunch.
|
||||
localStartDir: host.localStartDir || "~",
|
||||
} : {}),
|
||||
...(host.ephemeral ? { ephemeralHost: true } : {}),
|
||||
...(host.autoOpenSftpPanel ? { autoOpenSidePanel: "sftp" as const } : {}),
|
||||
};
|
||||
|
||||
@@ -224,31 +224,3 @@ test("split and copy session clones preserve isolated plugin connection snapshot
|
||||
assert.equal(split.reuseConnectionFromSessionId, undefined);
|
||||
assert.equal(copied.reuseConnectionFromSessionId, undefined);
|
||||
});
|
||||
|
||||
for (const clone of [createSplitTerminalSessionClone, createCopiedTerminalSessionClone]) {
|
||||
test(`${clone.name} keeps the type of an explicit local shell despite global settings`, () => {
|
||||
for (const [localShell, shellType] of [["cmd.exe", "cmd"], ["pwsh.exe", "powershell"]] as const) {
|
||||
const source = session({ protocol: "local", localShell, shellType });
|
||||
const copied = clone(source, { id: "clone", localShellType: "posix" });
|
||||
assert.equal(copied.localShell, localShell);
|
||||
assert.equal(copied.shellType, shellType);
|
||||
assert.equal(clone({ ...source, shellType: undefined },
|
||||
{ id: "legacy", localShellType: "posix" }).shellType, shellType);
|
||||
}
|
||||
const implicit = clone(session({ protocol: "local", shellType: "cmd" }),
|
||||
{ id: "clone-default", localShellType: "posix" });
|
||||
assert.equal(implicit.shellType, "posix", "implicit shells still follow global settings");
|
||||
});
|
||||
}
|
||||
|
||||
test("copied backend-default local snapshot retains its shell semantics after global settings change", () => {
|
||||
const source = session({ protocol: "local", localShell: "", localShellArgs: [], shellType: "powershell", localStartDir: "~" });
|
||||
for (const clone of [
|
||||
createCopiedTerminalSessionClone(source, { id: "copy-default-snapshot", localShellType: "posix" }),
|
||||
createSplitTerminalSessionClone(source, { id: "split-default-snapshot", localShellType: "posix" }),
|
||||
]) {
|
||||
assert.equal(clone.localShell, "");
|
||||
assert.equal(clone.shellType, "powershell");
|
||||
assert.equal(clone.localStartDir, "~");
|
||||
}
|
||||
});
|
||||
@@ -1,5 +1,4 @@
|
||||
import type { TerminalSession } from "../../domain/models";
|
||||
import { classifyLocalShellType } from "../../lib/localShell";
|
||||
|
||||
type SftpReuseCandidate = Pick<
|
||||
TerminalSession,
|
||||
@@ -45,12 +44,7 @@ function getClonedShellType(
|
||||
session: TerminalSession,
|
||||
localShellType?: TerminalSession["shellType"],
|
||||
): TerminalSession["shellType"] {
|
||||
if (session.protocol !== "local") return session.shellType;
|
||||
// Clones retain an explicit per-session executable, so its shell type must
|
||||
// survive a different global default (e.g. saved CMD with WSL in Settings).
|
||||
return session.localShell !== undefined
|
||||
? (session.shellType ?? classifyLocalShellType(session.localShell))
|
||||
: localShellType;
|
||||
return session.protocol === "local" ? localShellType : session.shellType;
|
||||
}
|
||||
|
||||
function createTerminalSessionClone(
|
||||
|
||||
@@ -86,11 +86,6 @@ import {
|
||||
retargetWorkspaceActiveChatAfterMemberLoss,
|
||||
seedWorkspaceAIActiveSessionFromMembers,
|
||||
} from '../../domain/workspaceAiScopeHandoff';
|
||||
import {
|
||||
buildUndoLastTurnBranch,
|
||||
type UndoLastTurnRestoredDraft,
|
||||
} from '../../domain/aiUndoLastTurn';
|
||||
|
||||
|
||||
function providerPatchIsNoop(
|
||||
current: ProviderConfig,
|
||||
@@ -702,32 +697,6 @@ export function useAIState() {
|
||||
return session;
|
||||
}, [defaultAgentId, persistSessions, setActiveSessionId]);
|
||||
|
||||
// Non-destructive "undo last turn": branch the session at the boundary
|
||||
// before the latest user message and keep the original in history intact.
|
||||
// Returns the new branch id plus the undone user message for the composer.
|
||||
const undoLastTurnInSession = useCallback(async (sessionId: string): Promise<{
|
||||
sessionId: string;
|
||||
restored: UndoLastTurnRestoredDraft;
|
||||
} | null> => {
|
||||
const nextId = `ai_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
|
||||
const source = sessionsRef.current.find(s => s.id === sessionId);
|
||||
if (!source) return null;
|
||||
const result = buildUndoLastTurnBranch(source, { newId: nextId, now: Date.now() });
|
||||
if (!result) return null;
|
||||
|
||||
const branched = result.session;
|
||||
setSessionsRaw(prev => {
|
||||
// Keep both recently used chats within the existing history limit.
|
||||
const next = [branched, { ...source, updatedAt: branched.updatedAt }, ...prev.filter(session => session.id !== source.id)];
|
||||
setLatestAISessionsSnapshot(next);
|
||||
persistSessions(next);
|
||||
return next;
|
||||
});
|
||||
const scopeKey = `${branched.scope.type}:${branched.scope.targetId ?? ''}`;
|
||||
setActiveSessionId(scopeKey, branched.id);
|
||||
return { sessionId: branched.id, restored: result.restored };
|
||||
}, [persistSessions, setActiveSessionId]);
|
||||
|
||||
const deleteSession = useCallback((sessionId: string, scopeKey?: string) => {
|
||||
cleanupDeletedAIChatSessions([sessionId]);
|
||||
if (persistTimerRef.current) {
|
||||
@@ -1306,7 +1275,6 @@ export function useAIState() {
|
||||
addDraftFiles,
|
||||
removeDraftFile,
|
||||
createSession,
|
||||
undoLastTurnInSession,
|
||||
deleteSession,
|
||||
deleteSessionsByTarget,
|
||||
updateSessionTitle,
|
||||
@@ -1367,7 +1335,6 @@ export function useAIState() {
|
||||
addDraftFiles,
|
||||
removeDraftFile,
|
||||
createSession,
|
||||
undoLastTurnInSession,
|
||||
deleteSession,
|
||||
deleteSessionsByTarget,
|
||||
updateSessionTitle,
|
||||
|
||||
@@ -1,194 +0,0 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import React, { act } from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import { JSDOM } from 'jsdom';
|
||||
|
||||
import type { AISession, UploadedFile } from '../../infrastructure/ai/types.ts';
|
||||
import { getAgentRuntime } from '../../infrastructure/ai/harness/globalAgentRuntime.ts';
|
||||
import { useAIState } from './useAIState.ts';
|
||||
import { useAISessionsStore } from './aiSessionsStore.ts';
|
||||
import { setLatestAISessionsSnapshot } from './aiStateSnapshots.ts';
|
||||
|
||||
const SCOPE_KEY = 'terminal:terminal-a';
|
||||
|
||||
const SOURCE_SESSION: AISession = {
|
||||
id: 'chat-source',
|
||||
title: 'Undo source',
|
||||
agentId: 'catty',
|
||||
scope: { type: 'terminal', targetId: 'terminal-a' },
|
||||
messages: [
|
||||
{ id: 'user-1', role: 'user', content: 'first prompt', timestamp: 1 },
|
||||
{ id: 'assistant-1', role: 'assistant', content: 'first answer', timestamp: 2 },
|
||||
{
|
||||
id: 'user-2',
|
||||
role: 'user',
|
||||
content: 'bad last prompt',
|
||||
timestamp: 3,
|
||||
attachments: [
|
||||
{ base64Data: 'QUJD', mediaType: 'text/plain', filename: 'note.txt', lineCount: 2 },
|
||||
],
|
||||
},
|
||||
{ id: 'assistant-2', role: 'assistant', content: 'bad answer', timestamp: 4 },
|
||||
],
|
||||
createdAt: 1,
|
||||
updatedAt: 4,
|
||||
};
|
||||
|
||||
type HookCapture = {
|
||||
ai: ReturnType<typeof useAIState> | null;
|
||||
sessions: AISession[];
|
||||
activeSessionIdMap: Record<string, string | null>;
|
||||
};
|
||||
|
||||
function UndoHarness({ capture }: { capture: HookCapture }) {
|
||||
capture.ai = useAIState();
|
||||
const store = useAISessionsStore();
|
||||
capture.sessions = store.sessions as AISession[];
|
||||
capture.activeSessionIdMap = store.activeSessionIdMap as Record<string, string | null>;
|
||||
return null;
|
||||
}
|
||||
|
||||
async function setupAiState(sessions: AISession[]) {
|
||||
const dom = new JSDOM('<!doctype html><html><body></body></html>', {
|
||||
url: 'http://localhost',
|
||||
});
|
||||
Object.defineProperty(globalThis, 'window', { configurable: true, value: dom.window });
|
||||
Object.defineProperty(globalThis, 'document', { configurable: true, value: dom.window.document });
|
||||
Object.defineProperty(globalThis, 'localStorage', { configurable: true, value: dom.window.localStorage });
|
||||
Object.defineProperty(globalThis, 'CustomEvent', { configurable: true, value: dom.window.CustomEvent });
|
||||
Object.defineProperty(globalThis, 'Event', { configurable: true, value: dom.window.Event });
|
||||
Object.defineProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT', { configurable: true, value: true });
|
||||
|
||||
setLatestAISessionsSnapshot(sessions);
|
||||
dom.window.localStorage.setItem('netcatty_ai_sessions_v1', JSON.stringify(sessions));
|
||||
|
||||
const container = document.createElement('div');
|
||||
document.body.appendChild(container);
|
||||
const root = createRoot(container);
|
||||
const capture: HookCapture = { ai: null, sessions: [], activeSessionIdMap: {} };
|
||||
await act(async () => {
|
||||
root.render(React.createElement(UndoHarness, { capture }));
|
||||
});
|
||||
return { dom, container, root, capture };
|
||||
}
|
||||
|
||||
test('undoLastTurnInSession branches and restores the last user turn', async () => {
|
||||
const { dom, container, root, capture } = await setupAiState([SOURCE_SESSION]);
|
||||
assert.ok(capture.ai);
|
||||
|
||||
let result: Awaited<ReturnType<typeof runUndo>> | null = null;
|
||||
async function runUndo() {
|
||||
return capture.ai!.undoLastTurnInSession('chat-source');
|
||||
}
|
||||
|
||||
await act(async () => {
|
||||
result = await runUndo();
|
||||
});
|
||||
|
||||
assert.ok(result);
|
||||
assert.notEqual(result!.sessionId, 'chat-source');
|
||||
|
||||
const sessions = capture.sessions;
|
||||
const branch = sessions.find((session) => session.id === result!.sessionId);
|
||||
assert.ok(branch);
|
||||
// Branch keeps the conversation up to the boundary before the last turn.
|
||||
assert.deepEqual(
|
||||
branch!.messages.map((message) => message.content),
|
||||
['first prompt', 'first answer'],
|
||||
);
|
||||
// Original session stays intact (non-destructive).
|
||||
const original = sessions.find((session) => session.id === 'chat-source');
|
||||
assert.equal(original?.messages.length, 4);
|
||||
// Branch becomes the active session for the scope.
|
||||
assert.equal(capture.activeSessionIdMap[SCOPE_KEY], result!.sessionId);
|
||||
|
||||
const attachments = result!.restored.attachments as UploadedFile[];
|
||||
assert.equal(result!.restored.text, 'bad last prompt');
|
||||
assert.equal(attachments.length, 1);
|
||||
assert.equal(attachments[0].filename, 'note.txt');
|
||||
assert.equal(attachments[0].dataUrl, 'data:text/plain;base64,QUJD');
|
||||
|
||||
await act(async () => root.unmount());
|
||||
container.remove();
|
||||
dom.window.close();
|
||||
});
|
||||
|
||||
test('undoLastTurnInSession returns null when there is nothing to undo', async () => {
|
||||
const emptySession: AISession = {
|
||||
...SOURCE_SESSION,
|
||||
id: 'chat-empty',
|
||||
messages: [],
|
||||
};
|
||||
const { dom, container, root, capture } = await setupAiState([emptySession]);
|
||||
assert.ok(capture.ai);
|
||||
|
||||
let result: unknown;
|
||||
await act(async () => {
|
||||
result = await capture.ai!.undoLastTurnInSession('chat-empty');
|
||||
});
|
||||
assert.equal(result, null);
|
||||
|
||||
await act(async () => root.unmount());
|
||||
container.remove();
|
||||
dom.window.close();
|
||||
});
|
||||
|
||||
test('undo copies chat only, preserving original outputs and job ownership', async () => {
|
||||
const runtime = getAgentRuntime();
|
||||
const outputStore = runtime.getToolOutputStore('chat-source');
|
||||
const handle = outputStore.store({
|
||||
chatSessionId: 'chat-source', capabilityId: 'terminal.exec', content: 'original saved output',
|
||||
});
|
||||
const stateStore = runtime.getSessionStateStore();
|
||||
stateStore.updateFromToolResult('chat-source', 'terminal_start',
|
||||
{ sessionId: 'terminal-a', command: 'long-running-command' },
|
||||
JSON.stringify({ jobId: 'original-job', status: 'running', nextOffset: 7 }), false);
|
||||
const source = structuredClone(SOURCE_SESSION);
|
||||
source.messages[1].content = `Preview with handleId=${handle.id}`;
|
||||
const { dom, container, root, capture } = await setupAiState([source]);
|
||||
const original = JSON.stringify(source);
|
||||
let result: { sessionId: string } | null = null;
|
||||
await act(async () => { result = await capture.ai!.undoLastTurnInSession(source.id); });
|
||||
assert.ok(result);
|
||||
assert.equal(outputStore.read({ handleId: handle.id }, result!.sessionId), null);
|
||||
assert.equal(outputStore.read({ handleId: handle.id }, source.id), 'original saved output');
|
||||
assert.deepEqual(stateStore.get(result!.sessionId).activeJobs, {});
|
||||
assert.ok(stateStore.get(source.id).activeJobs['original-job']);
|
||||
assert.equal(JSON.stringify({ ...capture.sessions.find(item => item.id === source.id), updatedAt: source.updatedAt }), original);
|
||||
assert.ok(capture.sessions.find(item => item.id === result!.sessionId)!.messages[1].content.includes('Preview'));
|
||||
stateStore.clear(source.id);
|
||||
outputStore.prune(source.id);
|
||||
await act(async () => root.unmount());
|
||||
container.remove(); dom.window.close();
|
||||
});
|
||||
|
||||
for (const newerCount of [49, 50]) {
|
||||
test(`undo retains an old source and its branch after reload with ${newerCount} newer chats`, async () => {
|
||||
const newer = Array.from({ length: newerCount }, (_, index) => ({
|
||||
...structuredClone(SOURCE_SESSION), id: `history-${index}`, updatedAt: 100 + index,
|
||||
}));
|
||||
const { dom, container, root, capture } = await setupAiState([...newer, SOURCE_SESSION]);
|
||||
let branchId: string | undefined;
|
||||
await act(async () => {
|
||||
branchId = (await capture.ai!.undoLastTurnInSession(SOURCE_SESSION.id))?.sessionId;
|
||||
});
|
||||
assert.ok(branchId);
|
||||
const saved: AISession[] = JSON.parse(dom.window.localStorage.getItem('netcatty_ai_sessions_v1')!);
|
||||
assert.equal(saved.length, 50);
|
||||
const original = saved.find(item => item.id === SOURCE_SESSION.id);
|
||||
assert.ok(original, 'the original must survive updatedAt-based history pruning');
|
||||
assert.deepEqual({ ...original, updatedAt: SOURCE_SESSION.updatedAt }, SOURCE_SESSION);
|
||||
assert.deepEqual(saved.find(item => item.id === branchId)?.messages, SOURCE_SESSION.messages.slice(0, 2));
|
||||
assert.ok(original.updatedAt >= newer.at(-1)!.updatedAt);
|
||||
await act(async () => root.unmount());
|
||||
container.remove(); dom.window.close();
|
||||
|
||||
// Remount the actual hook from the persisted records, as on app reload.
|
||||
const reloaded = await setupAiState(saved);
|
||||
assert.deepEqual(reloaded.capture.sessions.find(item => item.id === SOURCE_SESSION.id)?.messages, SOURCE_SESSION.messages);
|
||||
assert.deepEqual(reloaded.capture.sessions.find(item => item.id === branchId)?.messages, SOURCE_SESSION.messages.slice(0, 2));
|
||||
await act(async () => reloaded.root.unmount());
|
||||
reloaded.container.remove(); reloaded.dom.window.close();
|
||||
});
|
||||
}
|
||||
@@ -1,383 +0,0 @@
|
||||
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 { resolveHostDefaultLocalShell } from '../../domain/localShellHost';
|
||||
import { useHostOpenSessionReuse } from './useHostOpenSessionReuse';
|
||||
import { resolveShellSetting } from '../../lib/useDiscoveredShells';
|
||||
|
||||
(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, cwd and empty args match the renderer launch contract', 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())?.sessionId, 'tab', 'the renderer omits empty args so backend login defaults still apply');
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
|
||||
for (const owner of ['saved', 'global'] as const) {
|
||||
for (const [field, changed] of [
|
||||
['localShell', '/bin/zsh'], ['localShellArgs', ['--norc']], ['localStartDir', '/project-b'],
|
||||
] as const) {
|
||||
test(`local launch snapshot survives ${owner} ${field} edits and unchanged defaults reuse`, async (t) => {
|
||||
const settings = { localShell: '/bin/bash', localShellArgs: undefined as string[] | undefined, localStartDir: '/project-a' };
|
||||
const original = host({ protocol: 'local', hostname: 'localhost', ...(owner === 'saved' ? settings : {}) });
|
||||
const context = {
|
||||
terminalSettings: settings,
|
||||
resolveShellSetting: (command: string, _shells: DiscoveredShell[], args?: string[]) => ({ command, args }),
|
||||
};
|
||||
const resolveEffectiveHost = (candidate: Host) => resolveHostDefaultLocalShell(candidate, context);
|
||||
const launched = { ...createHostTerminalSession('launched', resolveEffectiveHost(original)), status: 'connected' as const };
|
||||
const api = await harness(original, { sessions: [launched], resolveEffectiveHost });
|
||||
t.after(api.close);
|
||||
assert.equal((await api.find())?.sessionId, launched.id, 'undefined settings args and [] snapshot launch identically');
|
||||
const edited = owner === 'saved' ? { ...original, [field]: changed } : original;
|
||||
if (owner === 'global') Object.assign(settings, { [field]: changed });
|
||||
await api.update({ host: edited });
|
||||
assert.equal(await api.find(), undefined, 'an edited launch must not return the old process');
|
||||
const copy = resolveTerminalSessionHost({ session: launched, hosts: [edited], groupConfigs: [], proxyProfiles: [], localOs: 'linux', defaultLocalShell: context });
|
||||
assert.equal(copy.localShell, '/bin/bash');
|
||||
assert.deepEqual(copy.localShellArgs, []);
|
||||
assert.equal(copy.localStartDir, '/project-a');
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
test('home and backend-default snapshots do not inherit later global or saved settings', async (t) => {
|
||||
const original = host({ protocol: 'local', hostname: 'localhost' });
|
||||
const settings: { localShell?: string; localStartDir?: string } = {};
|
||||
const context = { terminalSettings: settings, resolveShellSetting: (command: string) => command ? { command } : null };
|
||||
const resolveEffectiveHost = (candidate: Host) => resolveHostDefaultLocalShell(candidate, context);
|
||||
const launched = { ...createHostTerminalSession('home-default', resolveEffectiveHost(original)), status: 'connected' as const };
|
||||
assert.equal(launched.localShell, '');
|
||||
assert.equal(launched.localStartDir, '~');
|
||||
const api = await harness(original, { sessions: [launched], resolveEffectiveHost });
|
||||
t.after(api.close);
|
||||
assert.equal((await api.find())?.sessionId, launched.id);
|
||||
Object.assign(settings, { localShell: '/bin/bash', localStartDir: '/new-global' });
|
||||
const edited = { ...original, localShell: '/bin/zsh', localStartDir: '/new-saved' };
|
||||
await api.update({ host: edited });
|
||||
assert.equal(await api.find(), undefined);
|
||||
const copied = resolveTerminalSessionHost({ session: launched, hosts: [edited], groupConfigs: [], proxyProfiles: [], localOs: 'linux', defaultLocalShell: context });
|
||||
assert.equal(copied.localShell, '', 'empty captured executable still requests backend default');
|
||||
assert.equal(copied.localStartDir, '~', 'home remains home after a new global directory');
|
||||
});
|
||||
|
||||
test('saved discovery IDs resolve before snapshot while backend default stays independent of discovery', async (t) => {
|
||||
const saved = host({ protocol: 'local', hostname: 'localhost', localShell: 'bash' });
|
||||
const context = {
|
||||
discoveredShells: [{ id: 'bash', name: 'Bash', command: '/bin/bash', args: ['-l'], icon: 'bash', isDefault: true }],
|
||||
resolveShellSetting: (command: string) => command === 'bash' ? { command: '/bin/bash', args: ['-l'] } : null,
|
||||
};
|
||||
const resolveEffectiveHost = (candidate: Host) => resolveHostDefaultLocalShell(candidate, context);
|
||||
const launched = { ...createHostTerminalSession('discovered', resolveEffectiveHost(saved)), status: 'connected' as const };
|
||||
assert.equal(launched.localShell, '/bin/bash');
|
||||
const api = await harness(saved, { sessions: [launched], resolveEffectiveHost });
|
||||
t.after(api.close);
|
||||
assert.equal((await api.find())?.sessionId, launched.id);
|
||||
const defaultLaunch = resolveEffectiveHost({ ...saved, localShell: undefined });
|
||||
assert.equal(defaultLaunch.localShell, '');
|
||||
assert.equal(defaultLaunch.localShellArgs, undefined);
|
||||
});
|
||||
|
||||
for (const discovered of [
|
||||
{ id: 'bin-bash', name: 'Bash', command: '/bin/bash', args: ['-l'], icon: 'bash' },
|
||||
{ id: 'wsl-ubuntu', name: 'Ubuntu', command: 'C:\\Windows\\System32\\wsl.exe', args: ['-d', 'Ubuntu'], icon: 'ubuntu' },
|
||||
]) {
|
||||
for (const owner of ['saved', 'global'] as const) {
|
||||
test(`cold ${owner} ${discovered.id} reuses after discovery without masking edits`, async (t) => {
|
||||
const settings = { localShell: discovered.id, localStartDir: '/project-a' };
|
||||
const original = host({ protocol: 'local', hostname: 'localhost', ...(owner === 'saved' ? settings : {}) });
|
||||
const context = { terminalSettings: settings, discoveredShells: [] as DiscoveredShell[], resolveShellSetting };
|
||||
const resolveEffectiveHost = (candidate: Host) => resolveHostDefaultLocalShell(candidate, context);
|
||||
const launched = { ...createHostTerminalSession('cold', resolveEffectiveHost(original)), status: 'connected' as const };
|
||||
assert.equal(launched.localShell, discovered.id);
|
||||
const api = await harness(original, { sessions: [launched], resolveEffectiveHost });
|
||||
t.after(api.close);
|
||||
assert.equal((await api.find())?.sessionId, launched.id);
|
||||
context.discoveredShells = [discovered];
|
||||
await api.update({ host: { ...original } });
|
||||
assert.equal((await api.find())?.sessionId, launched.id, 'discovery resolves the same executable and default arguments');
|
||||
const edited = { ...original, localShell: discovered.command, localShellArgs: ['--edited'], localStartDir: '/project-b' };
|
||||
await api.update({ host: edited });
|
||||
assert.equal(await api.find(), undefined, 'a real path/argument/directory edit still opens a new session');
|
||||
const copy = resolveTerminalSessionHost({ session: launched, hosts: [edited], groupConfigs: [], proxyProfiles: [], localOs: 'linux', defaultLocalShell: context });
|
||||
assert.equal(copy.localShell, discovered.id);
|
||||
assert.deepEqual(copy.localShellArgs, []);
|
||||
assert.equal(copy.localStartDir, '/project-a');
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
test('discovery does not replace an explicit executable or its custom arguments with an alias', async (t) => {
|
||||
const original = host({ protocol: 'local', hostname: 'localhost', localShell: '/bin/bash', localShellArgs: ['--norc'] });
|
||||
const context = { discoveredShells: [] as DiscoveredShell[], resolveShellSetting };
|
||||
const resolveEffectiveHost = (candidate: Host) => resolveHostDefaultLocalShell(candidate, context);
|
||||
const api = await harness(original, { resolveEffectiveHost });
|
||||
t.after(api.close);
|
||||
context.discoveredShells = [{ id: 'bin-bash', name: 'Bash', command: '/bin/bash', args: ['-l'], icon: 'bash' }];
|
||||
await api.update({ host: { ...original } });
|
||||
assert.equal((await api.find())?.sessionId, 'tab');
|
||||
await api.update({ host: { ...original, localShell: 'bin-bash', localShellArgs: undefined } });
|
||||
assert.equal(await api.find(), undefined, 'selecting discovered login defaults changes the actual launch');
|
||||
});
|
||||
@@ -1,149 +0,0 @@
|
||||
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;
|
||||
};
|
||||
|
||||
type OpeningConnection = { localHost: Host } | { key: Promise<string | undefined> };
|
||||
|
||||
/** 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, OpeningConnection>());
|
||||
const connectionKey = useCallback((host: Host) => {
|
||||
const protocol = resolveEffectiveTerminalProtocol(host);
|
||||
if (protocol === 'local') {
|
||||
const options = {
|
||||
hostname: host.hostname,
|
||||
username: '',
|
||||
localShell: host.localShell || undefined,
|
||||
// The renderer starter omits both absent and empty args so the
|
||||
// backend chooses its discovered/login defaults for either form.
|
||||
localShellArgs: host.localShellArgs?.length ? host.localShellArgs : undefined,
|
||||
localStartDir: host.localStartDir || '~',
|
||||
};
|
||||
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 {
|
||||
const effectiveHost = resolveEffectiveHost(host);
|
||||
const openingHost = {
|
||||
...effectiveHost,
|
||||
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 ?? effectiveHost.localShell,
|
||||
localShellArgs: session.localShellArgs ?? effectiveHost.localShellArgs,
|
||||
localStartDir: session.localStartDir ?? effectiveHost.localStartDir,
|
||||
};
|
||||
// Local IDs may only resolve after renderer discovery completes. Keep
|
||||
// the opening settings so that later discovery can identify the same
|
||||
// executable without replacing them with edited vault/global settings.
|
||||
openingKeys.current.set(session.id, resolveEffectiveTerminalProtocol(openingHost) === 'local'
|
||||
? { localHost: openingHost }
|
||||
: { key: connectionKey(openingHost).catch(() => undefined) });
|
||||
} catch {
|
||||
openingKeys.current.set(session.id, { key: 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) => {
|
||||
if (session.hostId !== hostId) return undefined;
|
||||
const opening = openingKeys.current.get(session.id);
|
||||
const openingKey = opening && ('localHost' in opening
|
||||
// An explicit empty selector means backend default, not today's
|
||||
// global setting. Nonempty captured IDs can safely resolve now.
|
||||
? connectionKey(opening.localHost.localShell ? resolveEffectiveHost(opening.localHost) : opening.localHost)
|
||||
: opening.key);
|
||||
return await openingKey === 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]);
|
||||
}
|
||||
@@ -1098,12 +1098,17 @@ export const useSessionState = ({
|
||||
if (targetHosts.length === 0) return;
|
||||
const resolvedCommand = commandOverride ?? snippet.command;
|
||||
|
||||
// Create sessions for each target host. createHostTerminalSession copies
|
||||
// protocol, shellType, and local-shell metadata, so local vault hosts keep
|
||||
// their transport info (session consumers treat a missing protocol as SSH).
|
||||
const newSessions: TerminalSession[] = targetHosts.map(host =>
|
||||
createHostTerminalSession(crypto.randomUUID(), host),
|
||||
);
|
||||
// Create sessions for each target host
|
||||
const newSessions: TerminalSession[] = targetHosts.map(host => ({
|
||||
id: crypto.randomUUID(),
|
||||
hostId: host.id,
|
||||
hostLabel: host.label,
|
||||
hostname: host.hostname,
|
||||
username: host.username,
|
||||
status: 'connecting' as const,
|
||||
charset: host.charset,
|
||||
// workspaceId will be set after workspace is created
|
||||
}));
|
||||
|
||||
const sessionIds = newSessions.map(s => s.id);
|
||||
|
||||
|
||||
@@ -53,7 +53,6 @@ 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;
|
||||
}
|
||||
@@ -272,7 +271,6 @@ 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,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, { type Dispatch, type SetStateAction } from 'react';
|
||||
import { History, Plus, Undo2 } from 'lucide-react';
|
||||
import { History, Plus } from 'lucide-react';
|
||||
import type { AIPermissionMode, AISession, ChatMessage, DiscoveredAgent, ExternalAgentConfig, AgentModelPreset, ProviderConfig, UploadedFile } from '../infrastructure/ai/types';
|
||||
import type { Host, VaultNote } from '../types';
|
||||
import type { UserSkillOption } from './ai/userSkillsState';
|
||||
@@ -55,9 +55,6 @@ interface AIChatPanelContentProps {
|
||||
setInputValue: (value: string) => void;
|
||||
handleSend: () => void;
|
||||
handleCompact: () => void;
|
||||
/** Undo last turn is offered at a safe boundary (never mid-tool / mid-compaction). */
|
||||
canUndoLastTurn?: boolean;
|
||||
handleUndoLastTurn?: () => void;
|
||||
canCompact?: boolean;
|
||||
handleSteer: () => void;
|
||||
handleStop: () => void;
|
||||
@@ -135,8 +132,6 @@ export const AIChatPanelContent: React.FC<AIChatPanelContentProps> = ({
|
||||
setInputValue,
|
||||
handleSend,
|
||||
handleCompact,
|
||||
canUndoLastTurn = false,
|
||||
handleUndoLastTurn,
|
||||
canCompact = false,
|
||||
handleSteer,
|
||||
handleStop,
|
||||
@@ -237,23 +232,6 @@ export const AIChatPanelContent: React.FC<AIChatPanelContentProps> = ({
|
||||
onManageAgents={handleOpenSettings}
|
||||
/>
|
||||
<div className="flex items-center gap-0.5">
|
||||
{handleUndoLastTurn && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6 rounded-md text-muted-foreground/62 hover:bg-white/[0.05] hover:text-foreground"
|
||||
aria-label={t('ai.chat.undoLastTurn')}
|
||||
disabled={sending || isStreaming || !canUndoLastTurn}
|
||||
onClick={handleUndoLastTurn}
|
||||
>
|
||||
<Undo2 size={12} />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{t('ai.chat.undoLastTurn')}</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
<ConversationExport
|
||||
session={activeSession}
|
||||
onExport={handleExport}
|
||||
|
||||
@@ -120,9 +120,7 @@ test('send preflight aborts after the panel unmounts or the scope changes', () =
|
||||
test('send preflight locks header agent and new-chat controls', () => {
|
||||
const panel = readFileSync(new URL('./AIChatSidePanel.tsx', import.meta.url), 'utf8');
|
||||
const content = readFileSync(new URL('./AIChatPanelContent.tsx', import.meta.url), 'utf8');
|
||||
// `sending` also covers an in-flight undo-last-turn so the header controls
|
||||
// (and a quick send) stay locked while the branch materializes.
|
||||
assert.match(panel, /sending=\{isSending \|\| isUndoingLastTurn\}/);
|
||||
assert.match(panel, /sending=\{isSending\}/);
|
||||
assert.match(content, /disabled=\{sending\}/);
|
||||
const selector = readFileSync(new URL('./ai/AgentSelector.tsx', import.meta.url), 'utf8');
|
||||
assert.match(selector, /if \(parked \|\| disabled\) setOpen\(false\)/);
|
||||
|
||||
@@ -65,7 +65,6 @@ import {
|
||||
} from '../application/state/useAIChatStreaming';
|
||||
import { getScopedHistorySessions } from './ai/scopedHistorySessions';
|
||||
import { resolveInheritedAIActiveSessionId } from '../domain/aiWorkspaceScopeInherit';
|
||||
import { resolveUndoLastTurnBoundary } from '../domain/aiUndoLastTurn';
|
||||
import { aiSessionIdSetEqual, exactScopeAISessionsEqual } from '../domain/aiSessionsForScope';
|
||||
import { buildExternalAgentHistoryMessagesForBridge } from './ai/externalAgentHistory';
|
||||
import { canSendWithAgent, findEnabledExternalAgent } from './ai/agentSendEligibility';
|
||||
@@ -270,7 +269,6 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
|
||||
addDraftFiles,
|
||||
removeDraftFile,
|
||||
createSession,
|
||||
undoLastTurnInSession,
|
||||
deleteSession,
|
||||
updateSessionTitle,
|
||||
updateSessionExternalSessionId,
|
||||
@@ -321,11 +319,6 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
|
||||
|
||||
const [showHistory, setShowHistory] = useState(false);
|
||||
const [isSending, setIsSending] = useState(false);
|
||||
// Undo-last-turn is still
|
||||
// running. Unlike the send flow this is not reflected in `isSending`, so it
|
||||
// is tracked separately to block sends until the branch settles: a turn
|
||||
// submitted meanwhile would run against the (soon hidden) source session.
|
||||
const [isUndoingLastTurn, setIsUndoingLastTurn] = useState(false);
|
||||
const [runtimeAgentModelPresets, setRuntimeAgentModelPresets] = useState<Record<string, { cacheKey: string; models: AgentModelPreset[] }>>({});
|
||||
const [runtimeModelWarnings, setRuntimeModelWarnings] = useState<Record<string, { cacheKey: string; message: string }>>({});
|
||||
// Tracks SDK runtime model catalogs currently loading (per agent scope):
|
||||
@@ -828,78 +821,6 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
|
||||
&& Boolean(effectiveActiveProvider)
|
||||
&& Boolean(effectiveActiveModelId.trim());
|
||||
|
||||
// Undo last turn: offered only at a safe boundary (never inside a compacted
|
||||
// prefix, never between an assistant tool call and its result).
|
||||
const canUndoLastTurn = useMemo(() => Boolean(
|
||||
activeSession
|
||||
&& !isStreaming
|
||||
&& resolveUndoLastTurnBoundary(
|
||||
activeSession.messages,
|
||||
activeSession.contextCompaction?.compactedMessageCount ?? 0,
|
||||
) != null,
|
||||
), [activeSession, isStreaming]);
|
||||
|
||||
const undoInFlightRef = useRef(false);
|
||||
const handleUndoLastTurn = useCallback(async () => {
|
||||
const sessionId = activeSessionRef.current?.id;
|
||||
if (!sessionId || !undoLastTurnInSession) return;
|
||||
if (isStreaming || isAIChatSessionStreaming(sessionId)) return;
|
||||
// Serialize: while a branch is still being created, `canUndoLastTurn` stays stale, so a quick second
|
||||
// click must not start a parallel undo of the same session — that would
|
||||
// publish duplicate branches and restore the same prompt twice.
|
||||
if (undoInFlightRef.current) return;
|
||||
undoInFlightRef.current = true;
|
||||
setIsUndoingLastTurn(true);
|
||||
try {
|
||||
const result = await undoLastTurnInSession(sessionId);
|
||||
if (!result) return;
|
||||
|
||||
ensureScopeDraft(currentAgentId);
|
||||
// Text typed since the last flush lives only in the live composer buffer
|
||||
// (pendingComposerTextRef/currentDraftRef), not in the persisted draft;
|
||||
// merge from the live buffer so it is not lost, then drop the pending
|
||||
// buffer so the later flushDraftText() cannot overwrite the merged text.
|
||||
const liveDraft = currentDraftRef.current;
|
||||
updateScopeDraft(currentAgentId, (draft) => {
|
||||
const liveText = liveDraft?.text ?? draft.text;
|
||||
const liveSkillSlugs = liveDraft?.selectedUserSkillSlugs ?? draft.selectedUserSkillSlugs;
|
||||
return {
|
||||
...draft,
|
||||
// Keep anything the user is still typing; the undone prompt goes first.
|
||||
text: liveText.trim()
|
||||
? `${result.restored.text}\n\n${liveText}`
|
||||
: result.restored.text,
|
||||
attachments: [
|
||||
...result.restored.attachments,
|
||||
...(liveDraft?.attachments ?? draft.attachments),
|
||||
],
|
||||
// The undone turn was selected with these pills; keep any pills the
|
||||
// user picked since the send on top of them.
|
||||
selectedUserSkillSlugs: [
|
||||
...result.restored.selectedUserSkillSlugs,
|
||||
...liveSkillSlugs.filter((slug) => !result.restored.selectedUserSkillSlugs.includes(slug)),
|
||||
],
|
||||
};
|
||||
});
|
||||
discardPendingComposerText();
|
||||
showScopeSessionView(result.sessionId);
|
||||
// Tool side effects live outside the conversation and cannot be rolled back.
|
||||
toast.info(t('ai.chat.undoLastTurnNotice'));
|
||||
} finally {
|
||||
undoInFlightRef.current = false;
|
||||
setIsUndoingLastTurn(false);
|
||||
}
|
||||
}, [
|
||||
discardPendingComposerText,
|
||||
ensureScopeDraft,
|
||||
isStreaming,
|
||||
showScopeSessionView,
|
||||
t,
|
||||
undoLastTurnInSession,
|
||||
updateScopeDraft,
|
||||
currentAgentId,
|
||||
]);
|
||||
|
||||
const providersRef = useRef(providers);
|
||||
providersRef.current = providers;
|
||||
|
||||
@@ -1256,8 +1177,8 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
|
||||
|
||||
const inputAgentId = activeSession?.agentId ?? currentDraft?.agentId ?? currentAgentId;
|
||||
const canSendCurrentAgent = useMemo(
|
||||
() => !isSending && !isUndoingLastTurn && canSendWithAgent(inputAgentId, externalAgents),
|
||||
[inputAgentId, externalAgents, isSending, isUndoingLastTurn],
|
||||
() => !isSending && canSendWithAgent(inputAgentId, externalAgents),
|
||||
[inputAgentId, externalAgents, isSending],
|
||||
);
|
||||
|
||||
const handleAgentModelSelect = useCallback((modelId: string) => {
|
||||
@@ -1393,10 +1314,6 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
|
||||
}));
|
||||
const hasInlineTextAttachments = attachments.some(isInlineTextAttachment);
|
||||
if ((!trimmed && !hasInlineTextAttachments) || isStreaming) return;
|
||||
// While undo-last-turn is creating the branch, the target is still the source; a
|
||||
// turn submitted now would run in the soon-hidden source session, so
|
||||
// block it until the branch settles.
|
||||
if (isUndoingLastTurn) return;
|
||||
// Note-only sends (empty text + a mentioned note) still need a usable
|
||||
// session title: fall back to the first mentioned note's title so these
|
||||
// conversations don't all show up as "Untitled" in history.
|
||||
@@ -1521,7 +1438,6 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
|
||||
addMessageToSession(sessionId, {
|
||||
id: generateId(), role: 'user', content: trimmed,
|
||||
...(attachments.length > 0 ? { attachments } : {}),
|
||||
...(selectedSkillSlugs.length > 0 ? { selectedUserSkillSlugs: selectedSkillSlugs } : {}),
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
addMessageToSession(sessionId, { id: generateId(), role: 'assistant', content: t('ai.chat.noProvider'), timestamp: Date.now() });
|
||||
@@ -1536,7 +1452,6 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
|
||||
addMessageToSession(sessionId, {
|
||||
id: generateId(), role: 'user', content: trimmed,
|
||||
...(attachments.length > 0 ? { attachments } : {}),
|
||||
...(selectedSkillSlugs.length > 0 ? { selectedUserSkillSlugs: selectedSkillSlugs } : {}),
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
addMessageToSession(sessionId, { id: generateId(), role: 'assistant', content: t('ai.chat.noProviderModel'), timestamp: Date.now() });
|
||||
@@ -1550,8 +1465,6 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
|
||||
addMessageToSession(sessionId, {
|
||||
id: generateId(), role: 'user', content: trimmed,
|
||||
...(attachments.length > 0 ? { attachments } : {}),
|
||||
// Kept on the user message so undo last turn can restore the pills.
|
||||
...(selectedSkillSlugs.length > 0 ? { selectedUserSkillSlugs: selectedSkillSlugs } : {}),
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
clearScopeDraft({ keepPendingText: keepPendingAfterSend() });
|
||||
@@ -1633,7 +1546,7 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
|
||||
}
|
||||
}, [
|
||||
validateNoteMentions, loadCodexConfigModel,
|
||||
isStreaming, isUndoingLastTurn, activeProvider, effectiveActiveProvider, effectiveActiveModelId, selectedCattyThinking, scopeKey, currentAgentId,
|
||||
isStreaming, activeProvider, effectiveActiveProvider, effectiveActiveModelId, selectedCattyThinking, scopeKey, currentAgentId,
|
||||
activeModelId, externalAgents,
|
||||
createSession, addMessageToSession, updateMessageById, updateLastMessage,
|
||||
setStreamingForScope,
|
||||
@@ -1921,7 +1834,7 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
|
||||
>
|
||||
<AIChatPanelContent
|
||||
parked={!isVisible}
|
||||
sending={isSending || isUndoingLastTurn}
|
||||
sending={isSending}
|
||||
t={t}
|
||||
currentAgentId={currentAgentId}
|
||||
externalAgents={externalAgents}
|
||||
@@ -1951,8 +1864,6 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
|
||||
setInputValue={setInputValue}
|
||||
handleSend={handleSend}
|
||||
handleCompact={handleCompact}
|
||||
canUndoLastTurn={canUndoLastTurn}
|
||||
handleUndoLastTurn={undoLastTurnInSession ? handleUndoLastTurn : undefined}
|
||||
handleSteer={handleSteer}
|
||||
handleStop={handleStop}
|
||||
canSteer={canSteerCurrentTurn}
|
||||
|
||||
@@ -37,10 +37,6 @@ export interface AIChatSidePanelProps {
|
||||
addDraftFiles: (scopeKey: string, fallbackAgentId: string, inputFiles: File[]) => Promise<void>;
|
||||
removeDraftFile: (scopeKey: string, fallbackAgentId: string, fileId: string) => void;
|
||||
createSession: (scope: AISessionScope, agentId?: string) => AISession;
|
||||
/** Non-destructively branch before the last user turn; returns the branch id + restored composer content. */
|
||||
undoLastTurnInSession?: (
|
||||
sessionId: string,
|
||||
) => Promise<{ sessionId: string; restored: { text: string; attachments: import('../infrastructure/ai/types').UploadedFile[]; selectedUserSkillSlugs: string[] } } | null>;
|
||||
deleteSession: (sessionId: string, scopeKey?: string) => void;
|
||||
updateSessionTitle: (sessionId: string, title: string) => void;
|
||||
updateSessionExternalSessionId: (sessionId: string, externalSessionId: string | undefined) => void;
|
||||
|
||||
@@ -25,7 +25,6 @@ import {
|
||||
formatProxyConfigType,
|
||||
updateProxyConfigField,
|
||||
} from "../domain/proxyProfiles";
|
||||
import { canServeAsSshJumpHost } from "../domain/terminalProtocol";
|
||||
import {
|
||||
EnvVar,
|
||||
GroupConfig,
|
||||
@@ -334,12 +333,7 @@ const GroupDetailsPanel: React.FC<GroupDetailsPanelPropsWithResize> = ({
|
||||
|
||||
const availableHostsForChain = useMemo(() => {
|
||||
const chainedIds = new Set(form.hostChain?.hostIds || []);
|
||||
// Chain hops are SSH ProxyJump hops; never offer local/serial/telnet/plugin
|
||||
// entries (e.g. saved local shells) as jump hosts.
|
||||
return allHosts.filter((h) =>
|
||||
!chainedIds.has(h.id)
|
||||
&& canServeAsSshJumpHost(h),
|
||||
);
|
||||
return allHosts.filter((h) => !chainedIds.has(h.id));
|
||||
}, [allHosts, form.hostChain?.hostIds]);
|
||||
|
||||
const addHostToChain = (hostId: string) => {
|
||||
|
||||
@@ -23,7 +23,6 @@ import {
|
||||
updateProxyConfigField,
|
||||
} from "../domain/proxyProfiles";
|
||||
import { hasRequiredHostAuthCredential, resolveHostAuth, resolveHostAuthMethodForPersistence } from "../domain/sshAuth";
|
||||
import { canServeAsSshJumpHost } from "../domain/terminalProtocol";
|
||||
import { customThemeStore } from "../application/state/customThemeStore";
|
||||
import {
|
||||
hasHostFontSizeOverride,
|
||||
@@ -750,13 +749,7 @@ const HostDetailsPanel: React.FC<HostDetailsPanelPropsWithResize> = ({
|
||||
|
||||
const availableHostsForChain = useMemo(() => {
|
||||
const chainedIds = new Set(form.hostChain?.hostIds || []);
|
||||
// Chain hops are SSH ProxyJump hops; never offer local/serial/telnet/plugin
|
||||
// entries (e.g. saved local shells) as jump hosts.
|
||||
return allHosts.filter((h) =>
|
||||
h.id !== form.id
|
||||
&& !chainedIds.has(h.id)
|
||||
&& canServeAsSshJumpHost(h),
|
||||
);
|
||||
return allHosts.filter((h) => h.id !== form.id && !chainedIds.has(h.id));
|
||||
}, [allHosts, form.id, form.hostChain?.hostIds]);
|
||||
|
||||
const chainedHosts = useMemo(() => {
|
||||
|
||||
@@ -16,7 +16,6 @@ import {
|
||||
type HostClickBehavior,
|
||||
} from '../domain/hostClickBehavior';
|
||||
import { sortByVaultOrder } from '../domain/vaultOrder';
|
||||
import { getVaultHostRowSubtitle } from '../domain/localShellHost';
|
||||
import { STORAGE_KEY_VAULT_HOSTS_TREE_EXPANDED } from '../infrastructure/config/storageKeys';
|
||||
import { GroupConfig, GroupNode, Host } from '../types';
|
||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from './ui/collapsible';
|
||||
@@ -727,7 +726,6 @@ const HostTreeItem: React.FC<HostTreeItemProps> = ({
|
||||
initialTreeItemKey,
|
||||
onActiveTreeItemChange,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const safeHost = sanitizeHost(host);
|
||||
const tags = host.tags || [];
|
||||
const displayDetails = useMemo(
|
||||
@@ -845,9 +843,7 @@ const HostTreeItem: React.FC<HostTreeItemProps> = ({
|
||||
<HostNotesIndicator notes={host.notes} />
|
||||
</div>
|
||||
<div className="truncate text-[11px] leading-4 text-muted-foreground">
|
||||
{displayProtocol === 'local'
|
||||
? getVaultHostRowSubtitle(safeHost, { localFallback: t('localShell.subtitle') })
|
||||
: `${displayUsername}@${host.hostname}:${displayPort}`}
|
||||
{displayUsername}@{host.hostname}:{displayPort}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1,101 +0,0 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import React from "react";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { readFileSync } from "node:fs";
|
||||
|
||||
import { LocalShellHostDetailsPanel } from "./LocalShellHostDetailsPanel.tsx";
|
||||
import type { Host } from "../types.ts";
|
||||
|
||||
const panelSource = readFileSync(
|
||||
new URL("./LocalShellHostDetailsPanel.tsx", import.meta.url),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
const existingLocalHost = {
|
||||
id: "local-existing",
|
||||
label: "Project A CMD",
|
||||
hostname: "localhost",
|
||||
username: "",
|
||||
tags: ["local"],
|
||||
group: "Projects/A",
|
||||
protocol: "local",
|
||||
os: "linux",
|
||||
createdAt: 0,
|
||||
localShell: "powershell",
|
||||
localShellName: "Windows PowerShell",
|
||||
localShellIcon: "powershell",
|
||||
} as unknown as Host;
|
||||
|
||||
const noop = () => {};
|
||||
|
||||
test("renderToStaticMarkup: create mode shows title, default-shell option and Save & Connect", () => {
|
||||
const markup = renderToStaticMarkup(
|
||||
<LocalShellHostDetailsPanel
|
||||
defaultGroup="Projects/A"
|
||||
groups={["Projects/A", "Infra"]}
|
||||
onSave={noop}
|
||||
onSaveAndConnect={noop}
|
||||
onCancel={noop}
|
||||
layout="inline"
|
||||
/>,
|
||||
);
|
||||
|
||||
assert.match(markup, /localShell\.panel\.title/);
|
||||
assert.match(markup, /localShell\.field\.shellDefault/);
|
||||
assert.match(markup, /localShell\.saveAndConnect/);
|
||||
assert.match(markup, /localShell\.saveAndConnect|common\.save/);
|
||||
});
|
||||
|
||||
test("renderToStaticMarkup: edit mode hides Save & Connect and shows existing shell", () => {
|
||||
const markup = renderToStaticMarkup(
|
||||
<LocalShellHostDetailsPanel
|
||||
initialData={existingLocalHost}
|
||||
groups={["Projects/A"]}
|
||||
onSave={noop}
|
||||
onCancel={noop}
|
||||
layout="inline"
|
||||
/>,
|
||||
);
|
||||
|
||||
assert.match(markup, /localShell\.panel\.title\.edit/);
|
||||
assert.doesNotMatch(markup, /localShell\.saveAndConnect/);
|
||||
// Existing label is carried into the form
|
||||
assert.match(markup, /Project A CMD/);
|
||||
assert.match(markup, /local-shell-start-dir/);
|
||||
});
|
||||
|
||||
test("buildHost preserves the saved OS on edits and detects it only for new entries", () => {
|
||||
// Editing must retain initialData.os: entries imported/synced on one
|
||||
// platform must not be re-classified as the editor machine's OS on save.
|
||||
assert.match(
|
||||
panelSource,
|
||||
/os:\s*initialData\?\.os \?\? detectLocalOs\(navigator\.userAgent \|\| navigator\.platform\)/,
|
||||
);
|
||||
});
|
||||
|
||||
test("unchanged shell selection keeps stored metadata over discovery", () => {
|
||||
// A host with custom localShellArgs for a shell id that discovery also
|
||||
// matches must keep its stored args/name/icon when an unrelated edit
|
||||
// (label, tag, group) is saved; discovered metadata applies only after
|
||||
// the user changes the shell selection.
|
||||
const unchanged = panelSource.match(
|
||||
/const shellMeta =\s*\n[\s\S]*?shell === initialData\?\.localShell\s*\n\s*\?\s*\{[\s\S]*?\}\s*\n\s*: matchedShell;/,
|
||||
);
|
||||
assert.ok(unchanged, "shellMeta must branch on an unchanged shell selection");
|
||||
// Inside the unchanged branch, stored metadata wins per field and
|
||||
// matchedShell is only a fallback (never the primary source).
|
||||
assert.match(
|
||||
unchanged[0],
|
||||
/args:\s*initialData\?\.localShellArgs \?\? matchedShell\?\.args/,
|
||||
);
|
||||
assert.match(
|
||||
unchanged[0],
|
||||
/name:\s*initialData\?\.localShellName \?\? matchedShell\?\.name/,
|
||||
);
|
||||
assert.match(
|
||||
unchanged[0],
|
||||
/icon:\s*initialData\?\.localShellIcon \?\? matchedShell\?\.icon/,
|
||||
);
|
||||
assert.doesNotMatch(unchanged[0], /matchedShell \?\?/);
|
||||
});
|
||||
@@ -1,255 +0,0 @@
|
||||
/**
|
||||
* Local Shell Host Details Panel
|
||||
* A dedicated editor for local shell / CMD vault hosts (protocol 'local'),
|
||||
* distinct from the SSH HostDetailsPanel. Lets users save named local shells
|
||||
* into vault groups next to their SSH hosts (issue #3615).
|
||||
*/
|
||||
import { Save, TerminalSquare, X } from 'lucide-react';
|
||||
import React, { useMemo, useState } from 'react';
|
||||
import { useI18n } from '../application/i18n/I18nProvider';
|
||||
import { useDiscoveredShells } from '../lib/useDiscoveredShells';
|
||||
import { detectLocalOs } from '../lib/localShell';
|
||||
import type { Host } from '../domain/models';
|
||||
import { createLocalShellHost } from '../domain/localShellHost';
|
||||
|
||||
import { Button } from './ui/button';
|
||||
import { Combobox, ComboboxOption } from './ui/combobox';
|
||||
import { Input } from './ui/input';
|
||||
import { Label } from './ui/label';
|
||||
import {
|
||||
AsidePanel,
|
||||
AsidePanelContent,
|
||||
AsidePanelFooter,
|
||||
type AsidePanelLayout,
|
||||
type AsidePanelResizeProps,
|
||||
} from './ui/aside-panel';
|
||||
import { cn } from '../lib/utils';
|
||||
|
||||
interface LocalShellHostDetailsPanelProps {
|
||||
/** Existing host when editing; null/undefined when creating a new entry. */
|
||||
initialData?: Host | null;
|
||||
defaultGroup?: string;
|
||||
groups?: string[];
|
||||
onSave: (host: Host) => void;
|
||||
/** Saves the host and immediately opens a terminal for it. */
|
||||
onSaveAndConnect?: (host: Host) => void;
|
||||
onCancel: () => void;
|
||||
layout?: AsidePanelLayout;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
type LocalShellHostDetailsPanelPropsWithResize =
|
||||
LocalShellHostDetailsPanelProps & AsidePanelResizeProps;
|
||||
|
||||
export const LocalShellHostDetailsPanel: React.FC<LocalShellHostDetailsPanelPropsWithResize> = ({
|
||||
initialData,
|
||||
defaultGroup,
|
||||
groups = [],
|
||||
onSave,
|
||||
onSaveAndConnect,
|
||||
onCancel,
|
||||
layout = 'overlay',
|
||||
className,
|
||||
resizable,
|
||||
persistWidthStorageKey,
|
||||
resizeAriaLabel,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const discoveredShells = useDiscoveredShells();
|
||||
|
||||
const [label, setLabel] = useState(initialData?.label ?? '');
|
||||
const [shell, setShell] = useState(initialData?.localShell ?? '');
|
||||
const [startDir, setStartDir] = useState(initialData?.localStartDir ?? '');
|
||||
// On creation, initialize from defaultGroup so the field reflects what a
|
||||
// plain save would persist; clearing it then intentionally saves at the
|
||||
// vault root. Edits keep the host's own group (possibly empty).
|
||||
const [group, setGroup] = useState(
|
||||
initialData ? initialData.group ?? '' : defaultGroup ?? '',
|
||||
);
|
||||
|
||||
const matchedShell = useMemo(
|
||||
() => discoveredShells.find((candidate) => candidate.id === shell),
|
||||
[discoveredShells, shell],
|
||||
);
|
||||
|
||||
// While the shell selection is unchanged from the saved host, prefer the
|
||||
// host's stored metadata: an imported/synced host may carry custom startup
|
||||
// arguments for a shell id that also exists in discovery, and saving an
|
||||
// unrelated edit (label, tag, group) must not overwrite them with the
|
||||
// current discovery result. Discovered metadata applies only after the user
|
||||
// changes the selection — or per-field when the host stores none.
|
||||
const shellMeta =
|
||||
shell && shell === initialData?.localShell
|
||||
? {
|
||||
args: initialData?.localShellArgs ?? matchedShell?.args,
|
||||
name: initialData?.localShellName ?? matchedShell?.name,
|
||||
icon: initialData?.localShellIcon ?? matchedShell?.icon,
|
||||
}
|
||||
: matchedShell;
|
||||
|
||||
const shellOptions: ComboboxOption[] = useMemo(() => {
|
||||
const options: ComboboxOption[] = [
|
||||
{ value: '', label: t('localShell.field.shellDefault') },
|
||||
];
|
||||
for (const candidate of discoveredShells) {
|
||||
options.push({
|
||||
value: candidate.id,
|
||||
label: candidate.name,
|
||||
sublabel: candidate.command,
|
||||
});
|
||||
}
|
||||
return options;
|
||||
}, [discoveredShells, t]);
|
||||
|
||||
const groupOptions: ComboboxOption[] = useMemo(() => {
|
||||
const allGroups = new Set(groups);
|
||||
if (group && !allGroups.has(group)) {
|
||||
allGroups.add(group);
|
||||
}
|
||||
return Array.from(allGroups).map((g) => ({
|
||||
value: g,
|
||||
label: g,
|
||||
}));
|
||||
}, [groups, group]);
|
||||
|
||||
const buildHost = (): Host => {
|
||||
const host = createLocalShellHost({
|
||||
id: initialData?.id,
|
||||
// Keep the entry's saved OS on edits: importing/syncing on one platform
|
||||
// then editing elsewhere must not silently re-classify the host (shell
|
||||
// defaults, CSV round trips, dedup) as the editor machine's OS.
|
||||
os: initialData?.os ?? detectLocalOs(navigator.userAgent || navigator.platform),
|
||||
label,
|
||||
shell,
|
||||
shellArgs: shellMeta?.args,
|
||||
shellName: shellMeta?.name,
|
||||
shellIcon: shellMeta?.icon,
|
||||
startDir,
|
||||
// Save the controlled value directly: '' means the user explicitly
|
||||
// cleared the group, which persists at the vault root.
|
||||
group: group,
|
||||
tags: initialData?.tags,
|
||||
notes: initialData?.notes,
|
||||
});
|
||||
// On edits, merge over the existing host so fields this form does not
|
||||
// expose (pinned, lastConnectedAt, order, ...) and the original
|
||||
// creation timestamp survive a save.
|
||||
return initialData
|
||||
? { ...initialData, ...host, createdAt: initialData.createdAt ?? host.createdAt }
|
||||
: host;
|
||||
};
|
||||
|
||||
const handleSave = () => onSave(buildHost());
|
||||
const handleSaveAndConnect = () => {
|
||||
if (!onSaveAndConnect) return;
|
||||
onSaveAndConnect(buildHost());
|
||||
};
|
||||
|
||||
return (
|
||||
<AsidePanel
|
||||
open={true}
|
||||
onClose={onCancel}
|
||||
title={initialData ? t('localShell.panel.title.edit') : t('localShell.panel.title')}
|
||||
subtitle={initialData?.label}
|
||||
className={cn('z-40', className)}
|
||||
layout={layout}
|
||||
dataSection="local-shell-host-details-panel"
|
||||
resizable={resizable}
|
||||
persistWidthStorageKey={persistWidthStorageKey}
|
||||
resizeAriaLabel={resizeAriaLabel}
|
||||
>
|
||||
<AsidePanelContent>
|
||||
{/* Label */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="local-shell-label">{t('localShell.field.label')}</Label>
|
||||
<Input
|
||||
id="local-shell-label"
|
||||
value={label}
|
||||
onChange={(e) => setLabel(e.target.value)}
|
||||
placeholder={t('localShell.field.labelPlaceholder')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Shell */}
|
||||
<div className="space-y-2">
|
||||
<Label>{t('localShell.field.shell')}</Label>
|
||||
<Combobox
|
||||
options={shellOptions}
|
||||
value={shell}
|
||||
onValueChange={setShell}
|
||||
placeholder={t('localShell.field.shellDefault')}
|
||||
emptyText={t('localShell.field.shellEmpty')}
|
||||
allowCreate
|
||||
createText={t('common.use')}
|
||||
icon={<TerminalSquare size={14} className="text-muted-foreground" />}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{matchedShell
|
||||
? t('localShell.field.shellResolved', { command: matchedShell.command })
|
||||
: t('localShell.field.shellDefaultDesc')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Group */}
|
||||
<div className="space-y-2">
|
||||
<Label>{t('hostDetails.group')}</Label>
|
||||
<Combobox
|
||||
options={groupOptions}
|
||||
value={group}
|
||||
onValueChange={setGroup}
|
||||
placeholder={t('hostDetails.selectGroup')}
|
||||
allowCreate
|
||||
createText={t('hostDetails.createGroup')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Start directory */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="local-shell-start-dir">{t('localShell.field.startDir')}</Label>
|
||||
<div className="relative">
|
||||
<Input
|
||||
id="local-shell-start-dir"
|
||||
value={startDir}
|
||||
onChange={(e) => setStartDir(e.target.value)}
|
||||
placeholder={t('localShell.field.startDirPlaceholder')}
|
||||
className="pr-8"
|
||||
/>
|
||||
{startDir && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setStartDir('')}
|
||||
aria-label={t('common.reset')}
|
||||
className="absolute right-2 top-1/2 -translate-y-1/2 p-1 text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
<X size={14} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</AsidePanelContent>
|
||||
|
||||
<AsidePanelFooter>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="ghost" onClick={onCancel} className="flex-1">
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
{onSaveAndConnect && !initialData && (
|
||||
<Button
|
||||
onClick={handleSaveAndConnect}
|
||||
className="flex-1"
|
||||
>
|
||||
<TerminalSquare size={14} className="mr-2" />
|
||||
{t('localShell.saveAndConnect')}
|
||||
</Button>
|
||||
)}
|
||||
<Button onClick={handleSave} className="flex-1">
|
||||
<Save size={14} className="mr-2" />
|
||||
{t('common.save')}
|
||||
</Button>
|
||||
</div>
|
||||
</AsidePanelFooter>
|
||||
</AsidePanel>
|
||||
);
|
||||
};
|
||||
|
||||
export default LocalShellHostDetailsPanel;
|
||||
@@ -30,8 +30,6 @@ interface SelectHostPanelProps {
|
||||
resizable?: boolean;
|
||||
persistWidthStorageKey?: string;
|
||||
resizeAriaLabel?: string;
|
||||
/** Allow saved local-shell (CMD/PowerShell/...) hosts as targets, e.g. snippet/script runners. */
|
||||
allowLocalHosts?: boolean;
|
||||
}
|
||||
|
||||
const SelectHostPanel: React.FC<SelectHostPanelProps> = ({
|
||||
@@ -58,7 +56,6 @@ const SelectHostPanel: React.FC<SelectHostPanelProps> = ({
|
||||
resizable = false,
|
||||
persistWidthStorageKey,
|
||||
resizeAriaLabel,
|
||||
allowLocalHosts = false,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const [newHostPanelOpen, setNewHostPanelOpen] = useState(false);
|
||||
@@ -106,7 +103,6 @@ const SelectHostPanel: React.FC<SelectHostPanelProps> = ({
|
||||
onSaveHost={onSaveHost}
|
||||
onCreateGroup={onCreateGroup}
|
||||
onNewHostPanelOpenChange={setNewHostPanelOpen}
|
||||
allowLocalHosts={allowLocalHosts}
|
||||
className="flex-1 min-h-0"
|
||||
/>
|
||||
</AsidePanel>
|
||||
|
||||
@@ -58,8 +58,6 @@ export interface SelectHostPanelContentProps {
|
||||
onSaveHost?: (host: Host) => void;
|
||||
onCreateGroup?: (groupPath: string) => void;
|
||||
onNewHostPanelOpenChange?: (open: boolean) => void;
|
||||
/** Allow saved local-shell (CMD/PowerShell/...) hosts as targets, e.g. snippet/script runners. */
|
||||
allowLocalHosts?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
@@ -87,7 +85,6 @@ export const SelectHostPanelContent: React.FC<SelectHostPanelContentProps> = ({
|
||||
onSaveHost,
|
||||
onCreateGroup,
|
||||
onNewHostPanelOpenChange,
|
||||
allowLocalHosts = false,
|
||||
className,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
@@ -109,14 +106,9 @@ export const SelectHostPanelContent: React.FC<SelectHostPanelContentProps> = ({
|
||||
onNewHostPanelOpenChange?.(showNewHostPanel);
|
||||
}, [onNewHostPanelOpenChange, showNewHostPanel]);
|
||||
|
||||
// This panel picks hosts for SSH-backed capabilities (tunnels, keychain
|
||||
// key export targets); serial hosts have no SSH session to target and, by
|
||||
// default, neither do local-shell hosts. Snippet/script target pickers pass
|
||||
// `allowLocalHosts` because their domain treats every non-serial host
|
||||
// (including saved CMD/PowerShell shells) as a runnable target.
|
||||
const selectableHosts = useMemo(
|
||||
() => hosts.filter((host) => host.protocol !== 'serial' && (allowLocalHosts || host.protocol !== 'local')),
|
||||
[hosts, allowLocalHosts],
|
||||
() => hosts.filter((host) => host.protocol !== 'serial'),
|
||||
[hosts],
|
||||
);
|
||||
const selectedHostIdSet = useMemo(
|
||||
() => new Set(selectedHostIds),
|
||||
|
||||
@@ -132,7 +132,6 @@ export const SnippetsRightPanel: React.FC<SnippetsRightPanelProps> = ({
|
||||
customGroups={customGroups}
|
||||
selectedHostIds={targetSelection}
|
||||
multiSelect={true}
|
||||
allowLocalHosts={true}
|
||||
onSelect={handleTargetSelect}
|
||||
onSelectionChange={handleTargetSelectionChange}
|
||||
onBack={handleTargetPickerBack}
|
||||
|
||||
@@ -50,7 +50,6 @@ import {
|
||||
resolveTerminalChainHosts,
|
||||
resolveTerminalSessionHost,
|
||||
} from '../domain/terminalHostResolution';
|
||||
import { useDefaultLocalShellContext } from '../lib/useDiscoveredShells';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from './ui/tooltip';
|
||||
import { toast } from './ui/toast';
|
||||
import { useI18n } from '../application/i18n/I18nProvider';
|
||||
@@ -183,7 +182,7 @@ function buildScriptSessionMeta(
|
||||
connected: session.status === 'connected',
|
||||
name: session.customName || session.hostLabel || host?.label,
|
||||
hostname: host?.hostname ?? session.hostname,
|
||||
username: session.protocol === 'local' ? session.username : host?.username ?? session.username,
|
||||
username: host?.username ?? session.username,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1196,16 +1195,14 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
() => new Set(proxyProfiles.map((profile) => profile.id)),
|
||||
[proxyProfiles],
|
||||
);
|
||||
const defaultLocalShell = useDefaultLocalShellContext(terminalSettings);
|
||||
const effectiveHosts = useMemo(
|
||||
() => hosts.map((host) => resolveEffectiveTerminalHost({
|
||||
host,
|
||||
groupConfigs,
|
||||
proxyProfiles,
|
||||
validProxyProfileIds: proxyProfileIdSet,
|
||||
defaultLocalShell,
|
||||
})),
|
||||
[groupConfigs, hosts, proxyProfileIdSet, proxyProfiles, defaultLocalShell],
|
||||
[groupConfigs, hosts, proxyProfileIdSet, proxyProfiles],
|
||||
);
|
||||
|
||||
// Pre-compute fallback hosts to avoid creating new objects on every render
|
||||
@@ -1218,12 +1215,11 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
groupConfigs,
|
||||
proxyProfiles,
|
||||
localOs: detectLocalOs(navigator.userAgent || navigator.platform),
|
||||
defaultLocalShell,
|
||||
});
|
||||
map.set(session.id, applySessionFontSizeToHost(hostForSession, session));
|
||||
}
|
||||
return map;
|
||||
}, [sessions, hosts, groupConfigs, proxyProfiles, defaultLocalShell]);
|
||||
}, [sessions, hosts, groupConfigs, proxyProfiles]);
|
||||
const resolvedSessionHostIds = useMemo(() => {
|
||||
const ids = new Set<string>();
|
||||
for (const session of sessions) {
|
||||
|
||||
@@ -5,7 +5,6 @@ import { readFileSync } from 'node:fs';
|
||||
import type { Host, TerminalSession } from '../types';
|
||||
import type { TerminalPopupPayload } from '../domain/systemManager/types';
|
||||
import { resolveTerminalPopupHost, resolveTerminalPopupReuseId } from './TerminalPopupPage';
|
||||
import { resolveShellSetting } from '../lib/useDiscoveredShells';
|
||||
|
||||
const source = readFileSync(new URL('./TerminalPopupPage.tsx', import.meta.url), 'utf8');
|
||||
const terminalSource = readFileSync(new URL('./Terminal.tsx', import.meta.url), 'utf8');
|
||||
@@ -54,36 +53,6 @@ test('resolveTerminalPopupHost honors source session transport over saved Mosh h
|
||||
assert.equal(host.etEnabled, false);
|
||||
});
|
||||
|
||||
test('resolveTerminalPopupHost resolves the settings shell for saved local hosts with a system default shell', () => {
|
||||
const localHost = vaultHost({
|
||||
protocol: 'local',
|
||||
hostname: 'localhost',
|
||||
username: '',
|
||||
port: undefined,
|
||||
localShell: undefined,
|
||||
});
|
||||
const defaultLocalShell = {
|
||||
discoveredShells: [],
|
||||
resolveShellSetting,
|
||||
terminalSettings: { localShell: 'fish', localShellArgs: [] },
|
||||
};
|
||||
|
||||
const withContext = resolveTerminalPopupHost(
|
||||
popupPayload(sourceSession({ protocol: 'local', port: undefined })),
|
||||
[localHost],
|
||||
{ defaultLocalShell },
|
||||
);
|
||||
assert.equal(withContext.localShell, 'fish');
|
||||
|
||||
// Without the context (e.g. callers that cannot resolve it) the vault host
|
||||
// keeps its saved (empty) shell — the caller decides how to handle it.
|
||||
const withoutContext = resolveTerminalPopupHost(
|
||||
popupPayload(sourceSession({ protocol: 'local', port: undefined })),
|
||||
[localHost],
|
||||
);
|
||||
assert.equal(withoutContext.localShell, undefined);
|
||||
});
|
||||
|
||||
test('resolveTerminalPopupHost still falls back to source session details when the host is missing', () => {
|
||||
const host = resolveTerminalPopupHost(popupPayload(sourceSession({ hostId: 'missing' })), []);
|
||||
|
||||
|
||||
@@ -12,14 +12,12 @@ import {
|
||||
} from '../application/state/resolveTerminalSessionExitIntent';
|
||||
import { upsertKnownHost } from '../domain/knownHosts';
|
||||
import { isPluginHostProtocol, sanitizePluginConnection } from '../domain/pluginConnection';
|
||||
import type { ResolveDefaultLocalShellContext } from '../domain/localShellHost';
|
||||
import { resolveTerminalChainHosts, resolveTerminalSessionHost } from '../domain/terminalHostResolution';
|
||||
import type { TerminalPopupPayload } from '../domain/systemManager/types';
|
||||
import type { GroupConfig, Host, ProxyProfile, TerminalTheme } from '../domain/models';
|
||||
import type { KnownHost } from '../types';
|
||||
import { getEffectiveKnownHosts } from '../infrastructure/syncHelpers';
|
||||
import { detectLocalOs } from '../lib/localShell';
|
||||
import { useDefaultLocalShellContext } from '../lib/useDiscoveredShells';
|
||||
import { cn } from '../lib/utils';
|
||||
import type { AppLockGateRenderContext } from './AppLockGate';
|
||||
import { PluginAuthenticationHost } from './plugins/PluginAuthenticationHost';
|
||||
@@ -235,7 +233,6 @@ export function resolveTerminalPopupHost(
|
||||
groupConfigs?: GroupConfig[];
|
||||
proxyProfiles?: ProxyProfile[];
|
||||
localOs?: Host['os'];
|
||||
defaultLocalShell?: ResolveDefaultLocalShellContext;
|
||||
} = {},
|
||||
): Host {
|
||||
const resolvedHost = resolveTerminalSessionHost({
|
||||
@@ -244,7 +241,6 @@ export function resolveTerminalPopupHost(
|
||||
groupConfigs: options.groupConfigs ?? [],
|
||||
proxyProfiles: options.proxyProfiles ?? [],
|
||||
localOs: options.localOs ?? 'linux',
|
||||
defaultLocalShell: options.defaultLocalShell,
|
||||
});
|
||||
return applySourceSessionConnectionOverrides(
|
||||
resolvedHost,
|
||||
@@ -370,17 +366,14 @@ function TerminalPopupPageInner({
|
||||
});
|
||||
}, [attachAuthorization, attachSessionId, handleClose, onPrepareClose]);
|
||||
|
||||
const defaultLocalShell = useDefaultLocalShellContext(settings.terminalSettings);
|
||||
|
||||
const host = useMemo(() => {
|
||||
if (!config) return null;
|
||||
return resolveTerminalPopupHost(config, hosts, {
|
||||
groupConfigs,
|
||||
proxyProfiles,
|
||||
localOs: detectLocalOs(navigator.userAgent || navigator.platform),
|
||||
defaultLocalShell,
|
||||
});
|
||||
}, [config, groupConfigs, hosts, proxyProfiles, defaultLocalShell]);
|
||||
}, [config, groupConfigs, hosts, proxyProfiles]);
|
||||
|
||||
const chainHosts = useMemo(() => resolveTerminalChainHosts({
|
||||
host,
|
||||
|
||||
@@ -1,111 +0,0 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import ts from "typescript";
|
||||
import { upsertHostById } from "../domain/host";
|
||||
import { saveWorkSurfaceHostDraft } from "../application/state/useWorkSurfaceHostEditor";
|
||||
import { createLocalShellHost } from "../domain/localShellHost";
|
||||
import type { Host } from "../types";
|
||||
|
||||
// Execute the actual Vault save callback with its persistence boundary stubbed.
|
||||
const source = readFileSync(new URL("./VaultView.tsx", import.meta.url), "utf8");
|
||||
const callback = source.match(/const handleSaveLocalShellHost = useCallback\((\(host: Host, connectNow\?: boolean\) => \{[\s\S]*?)\}, \[/)?.[1];
|
||||
assert.ok(callback);
|
||||
const compiled = ts.transpile(`const save = ${callback}};`, { target: ts.ScriptTarget.ES2022 });
|
||||
|
||||
for (const editing of [false, true]) {
|
||||
for (const connectNow of [false, true]) {
|
||||
test(`local shell save: editing=${editing}, connect=${connectNow}`, () => {
|
||||
const opened = createLocalShellHost({ id: "local-1", label: "old", tags: ["old"], notes: "clear me" });
|
||||
const latest = { ...opened, showLineTimestamps: true, lastConnectedAt: 42 };
|
||||
const draft = { ...opened, tags: ["edited"], notes: undefined };
|
||||
let saved: Host[] = [];
|
||||
let connected: Host | undefined;
|
||||
// Mirror useVaultState.updateHosts: hostsRef holds the freshest host
|
||||
// array and accepts an array or functional updater argument.
|
||||
const hostsRef: { current: Host[] } = { current: editing ? [latest] : [] };
|
||||
const save = new Function(
|
||||
"hosts", "editingLocalShellHost", "onUpdateHosts", "onConnect",
|
||||
"setIsLocalShellHostPanelOpen", "setEditingLocalShellHost",
|
||||
"upsertHostById", "saveWorkSurfaceHostDraft",
|
||||
`${compiled} return save;`,
|
||||
)(
|
||||
editing ? [latest] : [], editing ? opened : null,
|
||||
(data: Host[] | ((prev: Host[]) => Host[])) => {
|
||||
const next = typeof data === "function" ? data(hostsRef.current) : data;
|
||||
if (next) hostsRef.current = next;
|
||||
saved = next ?? hostsRef.current;
|
||||
},
|
||||
(host: Host) => { connected = host; },
|
||||
() => {}, () => {}, upsertHostById, saveWorkSurfaceHostDraft,
|
||||
);
|
||||
save(draft, connectNow);
|
||||
assert.equal(saved.length, 1);
|
||||
assert.deepEqual(saved[0], editing ? { ...latest, tags: ["edited"], notes: undefined } : draft);
|
||||
assert.deepEqual(connected, connectNow ? saved[0] : undefined);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Saving must apply the draft against the freshest host array, not the array
|
||||
// captured at render time — a concurrent vault write between render and Save
|
||||
// would otherwise be replaced wholesale by the stale snapshot.
|
||||
test("local shell save: updater merges against the latest host array", () => {
|
||||
const opened = createLocalShellHost({ id: "local-1", label: "old", tags: ["old"] });
|
||||
const draft = { ...opened, tags: ["edited"] };
|
||||
// A concurrent write lands after the render that captured `hosts`.
|
||||
const concurrent = createLocalShellHost({ id: "local-2", label: "later" });
|
||||
const renderedHosts = [opened];
|
||||
const actualHosts = [...renderedHosts, concurrent];
|
||||
// Mirror useVaultState.updateHosts: hostsRef holds the freshest host array
|
||||
// (including the concurrent write) and accepts an array or updater argument.
|
||||
let saved: Host[] = [];
|
||||
const hostsRef: { current: Host[] } = { current: actualHosts };
|
||||
const save = new Function(
|
||||
"hosts", "editingLocalShellHost", "onUpdateHosts", "onConnect",
|
||||
"setIsLocalShellHostPanelOpen", "setEditingLocalShellHost",
|
||||
"upsertHostById", "saveWorkSurfaceHostDraft",
|
||||
`${compiled} return save;`,
|
||||
)(
|
||||
renderedHosts, opened,
|
||||
(data: Host[] | ((prev: Host[]) => Host[])) => {
|
||||
const next = typeof data === "function" ? data(hostsRef.current) : data;
|
||||
if (next) hostsRef.current = next;
|
||||
saved = next ?? hostsRef.current;
|
||||
},
|
||||
() => {}, () => {}, () => {}, upsertHostById, saveWorkSurfaceHostDraft,
|
||||
);
|
||||
save(draft, false);
|
||||
assert.equal(saved.length, 2, "concurrently added host must survive the save");
|
||||
assert.ok(saved.some((host) => host.id === "local-2"));
|
||||
assert.ok(saved.some((host) => host.id === "local-1" && host.tags.includes("edited")));
|
||||
});
|
||||
|
||||
// Duplicating a local-shell host opens its copy under a newly generated ID
|
||||
// that is not in the vault yet; it must be saved as a new entry, not as an
|
||||
// edit (which would no-op and silently discard the duplicate).
|
||||
test("local shell save: duplicated host with a new id saves as a new entry", () => {
|
||||
const opened = createLocalShellHost({ label: "cmd (copy)" });
|
||||
const draft = { ...opened, notes: "tweaked" };
|
||||
let saved: Host[] = [];
|
||||
let connected: Host | undefined;
|
||||
const save = new Function(
|
||||
"hosts", "editingLocalShellHost", "onUpdateHosts", "onConnect",
|
||||
"setIsLocalShellHostPanelOpen", "setEditingLocalShellHost",
|
||||
"upsertHostById", "saveWorkSurfaceHostDraft",
|
||||
`${compiled} return save;`,
|
||||
)(
|
||||
[], opened,
|
||||
// Mirror useVaultState.updateHosts: accepts an array or a functional
|
||||
// updater applied against the freshest host array (here, the vault is
|
||||
// empty at render time and stays empty in prev too).
|
||||
(data: Host[] | ((prev: Host[]) => Host[])) => { saved = typeof data === "function" ? data(saved) : data; },
|
||||
(host: Host) => { connected = host; },
|
||||
() => {}, () => {}, upsertHostById, saveWorkSurfaceHostDraft,
|
||||
);
|
||||
save(draft, false);
|
||||
assert.equal(saved.length, 1);
|
||||
assert.equal(saved[0].id, draft.id);
|
||||
assert.equal(saved[0].notes, "tweaked");
|
||||
assert.equal(connected, undefined);
|
||||
});
|
||||
+12
-101
@@ -73,7 +73,6 @@ import {
|
||||
upsertHostById,
|
||||
} from "../domain/host";
|
||||
import { exportHostsToCsvWithStats } from "../domain/vaultImport";
|
||||
import { saveWorkSurfaceHostDraft, type WorkSurfaceHostEditorTarget } from "../application/state/useWorkSurfaceHostEditor";
|
||||
import { upsertKnownHost } from "../domain/knownHosts";
|
||||
import {
|
||||
remapSnippetTargetGroupPaths,
|
||||
@@ -239,9 +238,7 @@ interface VaultViewProps {
|
||||
onDeleteHost: (id: string) => void;
|
||||
onConnect: (host: Host) => void;
|
||||
onOpenHostFromNote?: (host: Host, source?: { noteId: string }) => void;
|
||||
onUpdateHosts: (
|
||||
hosts: Host[] | ((prev: Host[]) => Host[]),
|
||||
) => VaultHostPersistenceResult | Promise<VaultHostPersistenceResult>;
|
||||
onUpdateHosts: (hosts: Host[]) => VaultHostPersistenceResult | Promise<VaultHostPersistenceResult>;
|
||||
onReadPersistedHosts: () => Promise<Host[]>;
|
||||
onUpdateKeys: (keys: SSHKey[]) => void;
|
||||
onImportOrReuseKey: (draft: Partial<SSHKey>) => SSHKey;
|
||||
@@ -461,9 +458,6 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
|
||||
const [isHostPanelOpen, setIsHostPanelOpen] = useState(false);
|
||||
const [editingHost, setEditingHost] = useState<Host | null>(null);
|
||||
const [newHostGroupPath, setNewHostGroupPath] = useState<string | null>(null);
|
||||
// Local shell (CMD) host panel state — a vault host entry with protocol 'local'
|
||||
const [isLocalShellHostPanelOpen, setIsLocalShellHostPanelOpen] = useState(false);
|
||||
const [editingLocalShellHost, setEditingLocalShellHost] = useState<Host | null>(null);
|
||||
const [pendingOpenNoteId, setPendingOpenNoteId] = useState<string | null>(
|
||||
null,
|
||||
);
|
||||
@@ -479,17 +473,8 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
|
||||
setCurrentSection("hosts");
|
||||
setSelectedGroupPath(null);
|
||||
setNewHostGroupPath(null);
|
||||
if (deepLinkHostDraft.protocol === "local") {
|
||||
setEditingHost(null);
|
||||
setIsHostPanelOpen(false);
|
||||
setEditingLocalShellHost(deepLinkHostDraft);
|
||||
setIsLocalShellHostPanelOpen(true);
|
||||
} else {
|
||||
setIsLocalShellHostPanelOpen(false);
|
||||
setEditingLocalShellHost(null);
|
||||
setEditingHost(deepLinkHostDraft);
|
||||
setIsHostPanelOpen(true);
|
||||
}
|
||||
setEditingHost(deepLinkHostDraft);
|
||||
setIsHostPanelOpen(true);
|
||||
onDeepLinkHostDraftHandled?.();
|
||||
}, [deepLinkHostDraft, onDeepLinkHostDraftHandled]);
|
||||
|
||||
@@ -522,17 +507,17 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
|
||||
useEffect(() => {
|
||||
const currentIds = new Set(hosts.map((h) => h.id));
|
||||
// Check against previous IDs before updating the ref
|
||||
const editingDeleted = (openHost: Host | null) =>
|
||||
Boolean(openHost && knownHostIdsRef.current.has(openHost.id) && !currentIds.has(openHost.id));
|
||||
if (editingDeleted(editingHost) || editingDeleted(editingLocalShellHost)) {
|
||||
if (
|
||||
editingHost &&
|
||||
knownHostIdsRef.current.has(editingHost.id) &&
|
||||
!currentIds.has(editingHost.id)
|
||||
) {
|
||||
setIsHostPanelOpen(false);
|
||||
setEditingHost(null);
|
||||
setIsLocalShellHostPanelOpen(false);
|
||||
setEditingLocalShellHost(null);
|
||||
setNewHostGroupPath(null);
|
||||
}
|
||||
knownHostIdsRef.current = currentIds;
|
||||
}, [hosts, editingHost, editingLocalShellHost]);
|
||||
}, [hosts, editingHost]);
|
||||
|
||||
// Group panel state
|
||||
const [isGroupPanelOpen, setIsGroupPanelOpen] = useState(false);
|
||||
@@ -614,12 +599,8 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
|
||||
{},
|
||||
{ validProxyProfileIds: proxyProfileIdSet },
|
||||
);
|
||||
// Only prompt when Telnet is available but isn't the host's default
|
||||
// protocol, and only for SSH hosts: local-shell and serial hosts must
|
||||
// always launch their own transport, never an inherited group Telnet
|
||||
// flag. Hosts without an explicit protocol are legacy SSH entries.
|
||||
const effectiveProtocol = effective.protocol ?? "ssh";
|
||||
if (effectiveProtocol === "ssh" && effective.telnetEnabled) {
|
||||
// Only prompt when Telnet is available but isn't the host's default protocol.
|
||||
if (effective.telnetEnabled && effective.protocol !== "telnet") {
|
||||
setProtocolSelectHost(effective);
|
||||
} else if (effective.protocol === "telnet") {
|
||||
// Telnet-as-default wins over a stray moshEnabled flag.
|
||||
@@ -673,67 +654,13 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
|
||||
setIsGroupPanelOpen(false);
|
||||
setEditingGroupPath(null);
|
||||
setEditingHost(null);
|
||||
setIsLocalShellHostPanelOpen(false);
|
||||
setEditingLocalShellHost(null);
|
||||
setNewHostGroupPath(null);
|
||||
setIsHostPanelOpen(true);
|
||||
}, []);
|
||||
|
||||
// "Local shell (CMD)" entry under the New Host menu — opens the dedicated
|
||||
// local-shell host editor with the current vault group preselected.
|
||||
const handleNewLocalShellHost = useCallback(() => {
|
||||
setIsGroupPanelOpen(false);
|
||||
setEditingGroupPath(null);
|
||||
setEditingHost(null);
|
||||
setIsHostPanelOpen(false);
|
||||
setEditingLocalShellHost(null);
|
||||
setNewHostGroupPath(null);
|
||||
setIsLocalShellHostPanelOpen(true);
|
||||
}, []);
|
||||
|
||||
const handleCancelLocalShellHost = useCallback(() => {
|
||||
setIsLocalShellHostPanelOpen(false);
|
||||
setEditingLocalShellHost(null);
|
||||
}, []);
|
||||
|
||||
const handleSaveLocalShellHost = useCallback((host: Host, connectNow?: boolean) => {
|
||||
// A duplicated local host carries a freshly generated ID that is not in the
|
||||
// vault yet, so it must be saved as a new entry (edit mode would no-op).
|
||||
// (Note: the mode is derived from the rendered hosts snapshot only, so the
|
||||
// updater below can still merge against the up-to-date vault array.)
|
||||
const editingExistingHost = hosts.some((entry) => entry.id === editingLocalShellHost?.id);
|
||||
const target: WorkSurfaceHostEditorTarget = editingExistingHost && editingLocalShellHost
|
||||
? { mode: "edit", openedHost: editingLocalShellHost, requestId: 0 }
|
||||
: { mode: "new", defaultGroup: null, requestId: 0 };
|
||||
// Apply the draft against the freshest host array, not the one captured at
|
||||
// render time: another vault write (terminal metadata, sync) may have
|
||||
// updated hostsRef between render and Save, and submitting the captured
|
||||
// array would replace it wholesale.
|
||||
let savedHost: Host | null = null;
|
||||
onUpdateHosts((prevHosts) => {
|
||||
const updatedHosts = saveWorkSurfaceHostDraft(prevHosts, target, host);
|
||||
if (!updatedHosts) return prevHosts;
|
||||
savedHost = updatedHosts.find((entry) => entry.id === host.id) ?? null;
|
||||
return updatedHosts;
|
||||
});
|
||||
setIsLocalShellHostPanelOpen(false);
|
||||
setEditingLocalShellHost(null);
|
||||
if (connectNow && savedHost) onConnect(savedHost);
|
||||
}, [hosts, editingLocalShellHost, onConnect, onUpdateHosts]);
|
||||
|
||||
const handleEditHost = useCallback((host: Host) => {
|
||||
setIsGroupPanelOpen(false);
|
||||
setEditingGroupPath(null);
|
||||
// Local shell hosts route to their dedicated editor (no SSH auth fields).
|
||||
if (host.protocol === "local") {
|
||||
setEditingHost(null);
|
||||
setIsHostPanelOpen(false);
|
||||
setEditingLocalShellHost(host);
|
||||
setIsLocalShellHostPanelOpen(true);
|
||||
return;
|
||||
}
|
||||
setIsLocalShellHostPanelOpen(false);
|
||||
setEditingLocalShellHost(null);
|
||||
setEditingHost(host);
|
||||
setIsHostPanelOpen(true);
|
||||
}, []);
|
||||
@@ -760,15 +687,6 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
|
||||
lastConnectedAt: undefined,
|
||||
};
|
||||
// Open the edit panel with the duplicated host for modification
|
||||
if (duplicatedHost.protocol === "local") {
|
||||
setEditingHost(null);
|
||||
setIsHostPanelOpen(false);
|
||||
setEditingLocalShellHost(duplicatedHost);
|
||||
setIsLocalShellHostPanelOpen(true);
|
||||
return;
|
||||
}
|
||||
setEditingLocalShellHost(null);
|
||||
setIsLocalShellHostPanelOpen(false);
|
||||
setEditingHost(duplicatedHost);
|
||||
setIsHostPanelOpen(true);
|
||||
},
|
||||
@@ -1437,9 +1355,7 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
|
||||
const isHostsSectionActive = currentSection === "hosts";
|
||||
const hasHostsSidePanel =
|
||||
isHostsSectionActive &&
|
||||
((isGroupPanelOpen && !!editingGroupPath)
|
||||
|| isHostPanelOpen
|
||||
|| isLocalShellHostPanelOpen);
|
||||
((isGroupPanelOpen && !!editingGroupPath) || isHostPanelOpen);
|
||||
// Every host grid uses the same container-based fixed column count. This keeps
|
||||
// pinned/recent cards aligned with the virtualized main grid and prevents a
|
||||
// single card from stretching across the full row.
|
||||
@@ -1553,9 +1469,6 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
|
||||
handleHostConnect,
|
||||
handleImportFileSelected,
|
||||
handleNewHost,
|
||||
handleNewLocalShellHost,
|
||||
handleCancelLocalShellHost,
|
||||
handleSaveLocalShellHost,
|
||||
handleProtocolSelect,
|
||||
handleQuickConnect,
|
||||
handleQuickConnectSaveHost,
|
||||
@@ -1575,8 +1488,6 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
|
||||
isDeleteGroupOpen,
|
||||
isGroupPanelOpen,
|
||||
isHostPanelOpen,
|
||||
isLocalShellHostPanelOpen,
|
||||
editingLocalShellHost,
|
||||
isHostsSectionActive,
|
||||
isImportOpen,
|
||||
isMultiSelectMode,
|
||||
|
||||
@@ -20,10 +20,8 @@ test("vault header collapsed actions cannot retain hidden focus", () => {
|
||||
assert.match(vaultViewLayoutSource, /newHostActionsRef\.current\?\.contains\(activeElement\)/);
|
||||
assert.match(vaultViewLayoutSource, /sessionActionsRef\.current\?\.contains\(activeElement\)/);
|
||||
assert.match(vaultViewLayoutSource, /activeElement\.blur\(\)/);
|
||||
// Both the SSH/new-host panel and the local-shell host panel collapse the
|
||||
// header actions so hidden buttons cannot retain focus.
|
||||
assert.match(vaultViewLayoutSource, /aria-hidden=\{isHostAsideOpen \? true : undefined\}/);
|
||||
assert.match(vaultViewLayoutSource, /inert=\{isHostAsideOpen \? true : undefined\}/);
|
||||
assert.match(vaultViewLayoutSource, /aria-hidden=\{isHostPanelOpen \? true : undefined\}/);
|
||||
assert.match(vaultViewLayoutSource, /inert=\{isHostPanelOpen \? true : undefined\}/);
|
||||
});
|
||||
|
||||
test("vault sidebar toggle keeps an accessible action label", () => {
|
||||
|
||||
@@ -200,7 +200,6 @@ export const ScriptEditorModal: React.FC<ScriptEditorModalProps> = ({
|
||||
customGroups={customGroups}
|
||||
selectedHostIds={selectedHostIds}
|
||||
multiSelect
|
||||
allowLocalHosts={true}
|
||||
onSelect={onSelectHost}
|
||||
onSelectionChange={onSelectionChange}
|
||||
onConfirm={handleTargetsConfirm}
|
||||
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
sftpSourceSessionIdForHost,
|
||||
type SftpConnectedHostEntry,
|
||||
} from '../../domain/sftpConnectedHosts';
|
||||
import { canOpenDualPaneSftp } from '../../domain/sftpDualPaneOpen';
|
||||
import { isPluginHostProtocol } from '../../domain/pluginConnection';
|
||||
import { Host } from '../../types';
|
||||
import { DistroAvatar } from '../DistroAvatar';
|
||||
import { getQuickSwitcherRowStateClass, shouldUseQuickSwitcherPointerNavigation } from '../QuickSwitcher';
|
||||
@@ -95,11 +95,9 @@ const SftpHostPickerInner: React.FC<SftpHostPickerProps> = ({
|
||||
|
||||
const filteredHosts = useMemo(() => {
|
||||
return hosts.filter((h) => {
|
||||
// SFTP is an SSH-specific host capability. Reuse the dual-pane
|
||||
// opener's predicate so serial, local-shell (no SSH channel —
|
||||
// selecting one would SSH into localhost) and plugin protocols
|
||||
// are excluded the same way everywhere.
|
||||
if (!canOpenDualPaneSftp(h)) return false;
|
||||
// SFTP is an SSH-specific host capability. Plugin protocols may
|
||||
// provide arbitrary transports and cannot be treated as SSH.
|
||||
if (h.protocol === "serial" || isPluginHostProtocol(h.protocol)) return false;
|
||||
// Hide a saved host only when Connected already shows the same endpoint.
|
||||
// If the vault host was edited after connect, keep both: Live (old) + Saved (new).
|
||||
const connected = connectedByHostId.get(h.id);
|
||||
|
||||
@@ -1,104 +0,0 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import { createTerminalSessionStarters } from "./createTerminalSessionStarters";
|
||||
|
||||
const noop = () => undefined;
|
||||
|
||||
const makeCtx = (
|
||||
host: Record<string, unknown>,
|
||||
startLocalSession: (options: Record<string, unknown>) => Promise<string>,
|
||||
) => ({
|
||||
host: {
|
||||
id: "host-1",
|
||||
label: "Local Shell",
|
||||
hostname: "localhost",
|
||||
username: "",
|
||||
protocol: "local",
|
||||
...host,
|
||||
},
|
||||
keys: [],
|
||||
identities: [],
|
||||
resolvedChainHosts: [],
|
||||
sessionId: "session-1",
|
||||
terminalSettings: {},
|
||||
terminalBackend: {
|
||||
backendAvailable: () => true,
|
||||
telnetAvailable: () => true,
|
||||
moshAvailable: () => true,
|
||||
etAvailable: () => true,
|
||||
localAvailable: () => true,
|
||||
serialAvailable: () => true,
|
||||
execAvailable: () => true,
|
||||
startSSHSession: async () => "ssh-session",
|
||||
startLocalSession,
|
||||
onSessionData: () => noop,
|
||||
onSessionExit: () => noop,
|
||||
writeToSession: noop,
|
||||
resizeSession: noop,
|
||||
},
|
||||
sessionRef: { current: null },
|
||||
hasConnectedRef: { current: true },
|
||||
hasRunStartupCommandRef: { current: false },
|
||||
disposeDataRef: { current: null },
|
||||
disposeExitRef: { current: null },
|
||||
fitAddonRef: { current: null },
|
||||
serializeAddonRef: { current: null },
|
||||
pendingAuthRef: { current: null },
|
||||
updateStatus: noop,
|
||||
setStatus: noop,
|
||||
setError: noop,
|
||||
setNeedsAuth: noop,
|
||||
setAuthRetryMessage: noop,
|
||||
setAuthPassword: noop,
|
||||
setProgressLogs: noop,
|
||||
setProgressValue: noop,
|
||||
setChainProgress: noop,
|
||||
});
|
||||
|
||||
const term = {
|
||||
cols: 120,
|
||||
rows: 32,
|
||||
write: noop,
|
||||
writeln: noop,
|
||||
scrollToBottom: noop,
|
||||
};
|
||||
|
||||
test("startLocal omits an empty no-args snapshot so backend defaults apply", async () => {
|
||||
// Vault local hosts snapshot absent args as [] to keep launch-time no-args
|
||||
// semantics for clone/popup relaunch, but the snapshot must not be passed to
|
||||
// the bridge: an empty array is truthy and would suppress the bridge's
|
||||
// discovered-shell args and getLocalShellArgs fallbacks (login flags,
|
||||
// PowerShell -NoLogo).
|
||||
let started: Record<string, unknown> | null = null;
|
||||
const ctx = makeCtx(
|
||||
{ localShell: "fish", localShellArgs: [] },
|
||||
async (options) => {
|
||||
started = options;
|
||||
return "local-session";
|
||||
},
|
||||
);
|
||||
|
||||
await createTerminalSessionStarters(ctx as never).startLocal(term as never);
|
||||
|
||||
assert.ok(started);
|
||||
assert.equal((started as { shell?: string }).shell, "fish");
|
||||
assert.equal((started as { shellArgs?: string[] }).shellArgs, undefined);
|
||||
});
|
||||
|
||||
test("startLocal passes explicit local shell args through", async () => {
|
||||
let started: Record<string, unknown> | null = null;
|
||||
const ctx = makeCtx(
|
||||
{ localShell: "bash", localShellArgs: ["--login", "-i"] },
|
||||
async (options) => {
|
||||
started = options;
|
||||
return "local-session";
|
||||
},
|
||||
);
|
||||
|
||||
await createTerminalSessionStarters(ctx as never).startLocal(term as never);
|
||||
|
||||
assert.ok(started);
|
||||
assert.equal((started as { shell?: string }).shell, "bash");
|
||||
assert.deepEqual((started as { shellArgs?: string[] }).shellArgs, ["--login", "-i"]);
|
||||
});
|
||||
@@ -3653,13 +3653,7 @@ test("restored local reconnect runs the host startup command while automatic ret
|
||||
assert.deepEqual(sessionWrites, []);
|
||||
});
|
||||
|
||||
for (const { name, hostDirectory, globalDirectory, expectedDirectory } of [
|
||||
{ name: "uses saved directory before global default", hostDirectory: "/Users/alice/project", globalDirectory: "/Users/alice/default", expectedDirectory: "/Users/alice/project" },
|
||||
{ name: "inherits global directory when unset", hostDirectory: undefined, globalDirectory: "/Users/alice/default", expectedDirectory: "/Users/alice/default" },
|
||||
{ name: "inherits global directory when cleared", hostDirectory: "", globalDirectory: "/Users/alice/default", expectedDirectory: "/Users/alice/default" },
|
||||
{ name: "leaves directory unset for backend home default", hostDirectory: undefined, globalDirectory: undefined, expectedDirectory: undefined },
|
||||
]) {
|
||||
test(`local session start ${name}`, async () => {
|
||||
test("local session start uses per-session directory before global default", async () => {
|
||||
let capturedOptions: Record<string, unknown> | null = null;
|
||||
const terminalBackend = {
|
||||
backendAvailable: () => true,
|
||||
@@ -3691,17 +3685,16 @@ test(`local session start ${name}`, async () => {
|
||||
hostname: "local",
|
||||
username: "",
|
||||
protocol: "local",
|
||||
localStartDir: hostDirectory,
|
||||
localStartDir: "/Users/alice/project",
|
||||
},
|
||||
terminalSettings: { localStartDir: globalDirectory },
|
||||
terminalSettings: { localStartDir: "/Users/alice/default" },
|
||||
terminalBackend,
|
||||
});
|
||||
|
||||
await createTerminalSessionStarters(ctx as never).startLocal(createTermStub() as never);
|
||||
|
||||
assert.equal(capturedOptions?.cwd, expectedDirectory);
|
||||
assert.equal(capturedOptions?.cwd, "/Users/alice/project");
|
||||
});
|
||||
}
|
||||
|
||||
test("local session restores cwd before startup command after attaching", async () => {
|
||||
const sessionWrites: Array<{ id: string; data: string; automated?: boolean }> = [];
|
||||
|
||||
@@ -1835,14 +1835,8 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
|
||||
// which was already resolved to command+args and stored on the session object by App.tsx.
|
||||
// Only pass shell/shellArgs when we have concrete per-session values;
|
||||
// otherwise omit them so the backend uses its own default shell detection.
|
||||
// An empty array is truthy, so `|| undefined` does not filter it out.
|
||||
// Vault local hosts snapshot absent args as `[]` (to preserve
|
||||
// launch-time no-args semantics for clone/popup relaunch), but the
|
||||
// snapshot must still be omitted here: the bridge only applies its
|
||||
// discovered-shell args and getLocalShellArgs fallbacks when shellArgs
|
||||
// is absent (#1221).
|
||||
const sessionShell = ctx.host.localShell;
|
||||
const sessionShellArgs = ctx.host.localShellArgs?.length ? ctx.host.localShellArgs : undefined;
|
||||
const sessionShellArgs = ctx.host.localShellArgs;
|
||||
const localStartDir = ctx.host.localStartDir || ctx.terminalSettings?.localStartDir;
|
||||
|
||||
const id = await ctx.terminalBackend.startLocalSession({
|
||||
@@ -1850,7 +1844,7 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
|
||||
cols: term.cols,
|
||||
rows: term.rows,
|
||||
shell: sessionShell || undefined,
|
||||
shellArgs: sessionShellArgs,
|
||||
shellArgs: sessionShellArgs || undefined,
|
||||
cwd: localStartDir,
|
||||
env: {
|
||||
TERM: ctx.terminalSettings?.terminalEmulationType ?? "xterm-256color",
|
||||
|
||||
@@ -584,7 +584,6 @@ const AIChatPanelsHostInner: React.FC<AIChatPanelsHostProps> = ({
|
||||
addDraftFiles={aiConfig.addDraftFiles}
|
||||
removeDraftFile={aiConfig.removeDraftFile}
|
||||
createSession={aiConfig.createSession}
|
||||
undoLastTurnInSession={aiConfig.undoLastTurnInSession}
|
||||
deleteSession={aiConfig.deleteSession}
|
||||
updateSessionTitle={aiConfig.updateSessionTitle}
|
||||
updateSessionExternalSessionId={aiConfig.updateSessionExternalSessionId}
|
||||
|
||||
@@ -86,7 +86,7 @@ const SessionTabIcon: React.FC<{
|
||||
// Local protocol → shell-specific icon if available, else OS-specific icon
|
||||
if (protocol === 'local' || host?.protocol === 'local' || (!protocol && !host)) {
|
||||
// Use shell icon from discovery when available
|
||||
const iconId = shellIcon ?? host?.localShellIcon;
|
||||
const iconId = shellIcon || host?.localShellIcon;
|
||||
if (iconId) {
|
||||
return (
|
||||
<img
|
||||
|
||||
@@ -27,7 +27,6 @@ import {
|
||||
import type { GroupNode, Host } from "../../domain/models";
|
||||
import { isPluginHostProtocol } from "../../domain/pluginConnection";
|
||||
import { OpenDualPaneSftpMenuItem } from "../host/HostTreeContextMenus";
|
||||
import { getVaultHostRowSubtitle } from "../../domain/localShellHost";
|
||||
|
||||
type VaultHostListSectionContext = Record<string, any>;
|
||||
|
||||
@@ -599,7 +598,7 @@ export function VaultHostListSection({ ctx }: { ctx: VaultHostListSectionContext
|
||||
<HostNotesIndicator notes={safeHost.notes} />
|
||||
</div>
|
||||
<div className="text-[11px] text-muted-foreground font-mono truncate leading-4">
|
||||
{getVaultHostRowSubtitle(safeHost, { localFallback: t('localShell.subtitle') })}
|
||||
{safeHost.username}@{safeHost.hostname}
|
||||
</div>
|
||||
</div>
|
||||
{viewMode === "grid" && renderHostEditButton(host)}
|
||||
@@ -719,7 +718,7 @@ export function VaultHostListSection({ ctx }: { ctx: VaultHostListSectionContext
|
||||
<HostNotesIndicator notes={safeHost.notes} />
|
||||
</div>
|
||||
<div className="text-[11px] text-muted-foreground font-mono truncate leading-4">
|
||||
{getVaultHostRowSubtitle(safeHost, { localFallback: t('localShell.subtitle') })}
|
||||
{safeHost.username}@{safeHost.hostname}
|
||||
</div>
|
||||
</div>
|
||||
{viewMode === "grid" && renderHostEditButton(host)}
|
||||
@@ -1098,7 +1097,7 @@ export function VaultHostListSection({ ctx }: { ctx: VaultHostListSectionContext
|
||||
<HostNotesIndicator notes={safeHost.notes} />
|
||||
</div>
|
||||
<div className="text-[11px] text-muted-foreground font-mono truncate leading-4">
|
||||
{getVaultHostRowSubtitle(safeHost, { localFallback: t('localShell.subtitle') })}
|
||||
{safeHost.username}@{safeHost.hostname}
|
||||
</div>
|
||||
</div>
|
||||
{viewMode === "grid" && renderHostEditButton(host)}
|
||||
@@ -1248,7 +1247,7 @@ export function VaultHostListSection({ ctx }: { ctx: VaultHostListSectionContext
|
||||
<HostNotesIndicator notes={safeHost.notes} />
|
||||
</div>
|
||||
<div className="text-[11px] text-muted-foreground font-mono truncate leading-4">
|
||||
{getVaultHostRowSubtitle(safeHost, { localFallback: t('localShell.subtitle') })}
|
||||
{safeHost.username}@{safeHost.hostname}
|
||||
</div>
|
||||
</div>
|
||||
{viewMode === "grid" && renderHostEditButton(host)}
|
||||
|
||||
@@ -12,7 +12,6 @@ import {
|
||||
} from "../../domain/vaultGroupSelection";
|
||||
import { STORAGE_KEY_VAULT_HOST_PANEL_WIDTH } from "@/infrastructure/config/storageKeys.ts";
|
||||
import { VaultHostListSection } from "./VaultHostListSection";
|
||||
import { LocalShellHostDetailsPanel } from "../LocalShellHostDetailsPanel";
|
||||
import { VaultImportProgressPanel } from "./ImportVaultDialog";
|
||||
import {
|
||||
VaultHeaderSearch,
|
||||
@@ -173,7 +172,6 @@ export function VaultViewLayout({ ctx }: { ctx: VaultViewLayoutContext }) {
|
||||
Edit2,
|
||||
editingGroupPath,
|
||||
editingHost,
|
||||
editingLocalShellHost,
|
||||
editingHostGroupDefaults,
|
||||
FileCode,
|
||||
FileSymlink,
|
||||
@@ -197,9 +195,6 @@ export function VaultViewLayout({ ctx }: { ctx: VaultViewLayoutContext }) {
|
||||
handleHostConnect,
|
||||
handleImportFileSelected,
|
||||
handleNewHost,
|
||||
handleNewLocalShellHost,
|
||||
handleCancelLocalShellHost,
|
||||
handleSaveLocalShellHost,
|
||||
handleProtocolSelect,
|
||||
handleQuickConnect,
|
||||
handleQuickConnectSaveHost,
|
||||
@@ -219,7 +214,6 @@ export function VaultViewLayout({ ctx }: { ctx: VaultViewLayoutContext }) {
|
||||
isDeleteGroupOpen,
|
||||
isGroupPanelOpen,
|
||||
isHostPanelOpen,
|
||||
isLocalShellHostPanelOpen,
|
||||
isHostsSectionActive,
|
||||
isImportOpen,
|
||||
isMultiSelectMode,
|
||||
@@ -386,9 +380,6 @@ export function VaultViewLayout({ ctx }: { ctx: VaultViewLayoutContext }) {
|
||||
} = ctx;
|
||||
const { importProgress, onCommitPluginImporterData, resetImportProgress } =
|
||||
ctx;
|
||||
// Host aside panel (SSH/new host) or the local-shell host panel occupies the
|
||||
// aside area — both collapse the header action buttons the same way.
|
||||
const isHostAsideOpen = isHostPanelOpen || isLocalShellHostPanelOpen;
|
||||
const pendingDeleteGroupPaths =
|
||||
bulkDeleteGroupPaths.length > 0
|
||||
? bulkDeleteGroupPaths
|
||||
@@ -530,7 +521,7 @@ export function VaultViewLayout({ ctx }: { ctx: VaultViewLayoutContext }) {
|
||||
);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!isHostAsideOpen) return;
|
||||
if (!isHostPanelOpen) return;
|
||||
const activeElement = document.activeElement;
|
||||
if (!(activeElement instanceof HTMLElement)) return;
|
||||
if (
|
||||
@@ -539,7 +530,7 @@ export function VaultViewLayout({ ctx }: { ctx: VaultViewLayoutContext }) {
|
||||
) {
|
||||
activeElement.blur();
|
||||
}
|
||||
}, [isHostAsideOpen]);
|
||||
}, [isHostPanelOpen]);
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -970,12 +961,12 @@ export function VaultViewLayout({ ctx }: { ctx: VaultViewLayoutContext }) {
|
||||
ref={newHostActionsRef}
|
||||
className={cn(
|
||||
"flex items-center app-no-drag overflow-hidden transition-[max-width,opacity,margin] duration-200 ease-in-out",
|
||||
isHostAsideOpen
|
||||
isHostPanelOpen
|
||||
? "max-w-0 opacity-0 -ml-2 pointer-events-none"
|
||||
: "max-w-[260px] opacity-100",
|
||||
)}
|
||||
aria-hidden={isHostAsideOpen ? true : undefined}
|
||||
inert={isHostAsideOpen ? true : undefined}
|
||||
aria-hidden={isHostPanelOpen ? true : undefined}
|
||||
inert={isHostPanelOpen ? true : undefined}
|
||||
>
|
||||
<Dropdown>
|
||||
<div className="flex items-center rounded-md bg-primary text-primary-foreground">
|
||||
@@ -983,7 +974,7 @@ export function VaultViewLayout({ ctx }: { ctx: VaultViewLayoutContext }) {
|
||||
size="sm"
|
||||
className="h-10 px-3 rounded-r-none bg-transparent hover:bg-white/10 shadow-none"
|
||||
onClick={handleNewHost}
|
||||
tabIndex={isHostAsideOpen ? -1 : 0}
|
||||
tabIndex={isHostPanelOpen ? -1 : 0}
|
||||
>
|
||||
<Plus size={14} className="mr-2" />{" "}
|
||||
{t("vault.hosts.newHost")}
|
||||
@@ -992,20 +983,13 @@ export function VaultViewLayout({ ctx }: { ctx: VaultViewLayoutContext }) {
|
||||
<Button
|
||||
size="sm"
|
||||
className="h-10 px-2 rounded-l-none bg-transparent hover:bg-white/10 border-l border-primary-foreground/20 shadow-none"
|
||||
tabIndex={isHostAsideOpen ? -1 : 0}
|
||||
tabIndex={isHostPanelOpen ? -1 : 0}
|
||||
>
|
||||
<ChevronDown size={14} />
|
||||
</Button>
|
||||
</DropdownTrigger>
|
||||
</div>
|
||||
<DropdownContent className="w-44" align="end" alignToParent>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="w-full justify-start gap-2"
|
||||
onClick={handleNewLocalShellHost}
|
||||
>
|
||||
<TerminalSquare size={14} /> {t("vault.hosts.newLocalShell")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="w-full justify-start gap-2"
|
||||
@@ -1045,19 +1029,19 @@ export function VaultViewLayout({ ctx }: { ctx: VaultViewLayoutContext }) {
|
||||
ref={sessionActionsRef}
|
||||
className={cn(
|
||||
"flex items-center gap-3 overflow-hidden transition-[max-width,opacity,margin] duration-200 ease-in-out",
|
||||
isHostAsideOpen
|
||||
isHostPanelOpen
|
||||
? "max-w-0 opacity-0 -ml-3 pointer-events-none"
|
||||
: "max-w-[320px] opacity-100",
|
||||
)}
|
||||
aria-hidden={isHostAsideOpen ? true : undefined}
|
||||
inert={isHostAsideOpen ? true : undefined}
|
||||
aria-hidden={isHostPanelOpen ? true : undefined}
|
||||
inert={isHostPanelOpen ? true : undefined}
|
||||
>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
className={vaultHeaderSecondaryButtonClass}
|
||||
onClick={onCreateLocalTerminal}
|
||||
tabIndex={isHostAsideOpen ? -1 : 0}
|
||||
tabIndex={isHostPanelOpen ? -1 : 0}
|
||||
>
|
||||
<TerminalSquare size={14} className="mr-2" />{" "}
|
||||
{t("common.terminal")}
|
||||
@@ -1067,7 +1051,7 @@ export function VaultViewLayout({ ctx }: { ctx: VaultViewLayoutContext }) {
|
||||
variant="secondary"
|
||||
className={vaultHeaderSecondaryButtonClass}
|
||||
onClick={() => setIsSerialModalOpen(true)}
|
||||
tabIndex={isHostAsideOpen ? -1 : 0}
|
||||
tabIndex={isHostPanelOpen ? -1 : 0}
|
||||
>
|
||||
<Usb size={14} className="mr-2" /> {t("serial.button")}
|
||||
</Button>
|
||||
@@ -1543,26 +1527,6 @@ export function VaultViewLayout({ ctx }: { ctx: VaultViewLayoutContext }) {
|
||||
{...vaultHostPanelResizeProps}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Local Shell Host Details Panel - for editing/saving local shell (CMD) hosts */}
|
||||
{currentSection === "hosts" &&
|
||||
isLocalShellHostPanelOpen && (
|
||||
<LocalShellHostDetailsPanel
|
||||
key={editingLocalShellHost?.id ?? "local-shell-new"}
|
||||
initialData={editingLocalShellHost}
|
||||
groups={allGroupPaths}
|
||||
defaultGroup={
|
||||
editingLocalShellHost
|
||||
? undefined
|
||||
: newHostGroupPath || selectedGroupPath
|
||||
}
|
||||
onSave={(host) => handleSaveLocalShellHost(host)}
|
||||
onSaveAndConnect={(host) => handleSaveLocalShellHost(host, true)}
|
||||
onCancel={handleCancelLocalShellHost}
|
||||
layout="inline"
|
||||
{...vaultHostPanelResizeProps}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,245 +0,0 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import type { AISession, ChatMessage } from '../infrastructure/ai/types.ts';
|
||||
import {
|
||||
buildUndoLastTurnBranch,
|
||||
hasUnresolvedToolCalls,
|
||||
resolveUndoLastTurnBoundary,
|
||||
} from './aiUndoLastTurn.ts';
|
||||
|
||||
const user = (content: string): ChatMessage => ({
|
||||
id: `u_${content}`,
|
||||
role: 'user',
|
||||
content,
|
||||
timestamp: 1,
|
||||
});
|
||||
const assistant = (content: string, extra: Partial<ChatMessage> = {}): ChatMessage => ({
|
||||
id: `a_${content}`,
|
||||
role: 'assistant',
|
||||
content,
|
||||
timestamp: 2,
|
||||
...extra,
|
||||
});
|
||||
const session = (messages: ChatMessage[], extra: Partial<AISession> = {}): AISession => ({
|
||||
id: 'chat-1',
|
||||
title: 'Chat',
|
||||
agentId: 'catty',
|
||||
scope: { type: 'terminal', targetId: 't1' },
|
||||
messages,
|
||||
createdAt: 1,
|
||||
updatedAt: 2,
|
||||
...extra,
|
||||
});
|
||||
|
||||
test('resolveUndoLastTurnBoundary finds the last user message index', () => {
|
||||
const messages = [user('one'), assistant('reply'), user('two'), assistant('reply2')];
|
||||
assert.equal(resolveUndoLastTurnBoundary(messages), 2);
|
||||
});
|
||||
|
||||
test('resolveUndoLastTurnBoundary returns null without a user message', () => {
|
||||
assert.equal(resolveUndoLastTurnBoundary([assistant('hello')]), null);
|
||||
assert.equal(resolveUndoLastTurnBoundary([]), null);
|
||||
});
|
||||
|
||||
test('resolveUndoLastTurnBoundary refuses boundaries inside the compacted prefix', () => {
|
||||
const messages = [user('compacted'), assistant('x'), user('last'), assistant('y')];
|
||||
assert.equal(resolveUndoLastTurnBoundary(messages, 3), null);
|
||||
assert.equal(resolveUndoLastTurnBoundary(messages, 2), 2);
|
||||
});
|
||||
|
||||
test('resolveUndoLastTurnBoundary refuses cuts between a tool call and its result', () => {
|
||||
// The call sits in the kept prefix while its result would be cut away.
|
||||
const split = [
|
||||
assistant('', {
|
||||
toolCalls: [{ id: 'call-1', name: 'harness.terminal.exec', arguments: {} }],
|
||||
}),
|
||||
user('again?'),
|
||||
assistant('', {
|
||||
toolResults: [{ toolCallId: 'call-1', content: 'ok' }],
|
||||
}),
|
||||
];
|
||||
assert.equal(resolveUndoLastTurnBoundary(split), null);
|
||||
assert.ok(hasUnresolvedToolCalls(split.slice(0, 2)));
|
||||
// Result already inside the kept prefix — the boundary is safe.
|
||||
const resolved = [
|
||||
assistant('', {
|
||||
toolCalls: [{ id: 'call-1', name: 'harness.terminal.exec', arguments: {} }],
|
||||
toolResults: [{ toolCallId: 'call-1', content: 'ok' }],
|
||||
}),
|
||||
user('again?'),
|
||||
assistant('done'),
|
||||
];
|
||||
assert.equal(hasUnresolvedToolCalls(resolved.slice(0, 1)), false);
|
||||
assert.equal(resolveUndoLastTurnBoundary(resolved), 1);
|
||||
});
|
||||
|
||||
test('buildUndoLastTurnBranch branches before the last user turn and restores it', () => {
|
||||
const source = session([
|
||||
user('first'),
|
||||
assistant('answer'),
|
||||
user('bad prompt'),
|
||||
assistant('bad answer'),
|
||||
]);
|
||||
const result = buildUndoLastTurnBranch(source, { newId: 'chat-2', now: 42 });
|
||||
assert.ok(result);
|
||||
assert.equal(result.session.id, 'chat-2');
|
||||
assert.deepEqual(result.session.messages.map((m) => m.content), ['first', 'answer']);
|
||||
assert.equal(result.restored.text, 'bad prompt');
|
||||
// Original session untouched (non-destructive).
|
||||
assert.equal(source.messages.length, 4);
|
||||
});
|
||||
|
||||
test('buildUndoLastTurnBranch clears the external agent session id and keeps compaction', () => {
|
||||
const source = session(
|
||||
[user('compacted'), assistant('x'), user('last'), assistant('y')],
|
||||
{
|
||||
externalSessionId: 'prov-1',
|
||||
contextCompaction: { summary: 'summary', compactedMessageCount: 2 },
|
||||
},
|
||||
);
|
||||
const result = buildUndoLastTurnBranch(source, { newId: 'chat-2', now: 7 });
|
||||
assert.ok(result);
|
||||
assert.equal(result.session.externalSessionId, undefined);
|
||||
assert.deepEqual(result.session.contextCompaction, { summary: 'summary', compactedMessageCount: 2 });
|
||||
});
|
||||
|
||||
test('buildUndoLastTurnBranch restores message attachments into composer uploads', () => {
|
||||
const source = session([
|
||||
user('with file'),
|
||||
assistant('ok'),
|
||||
{
|
||||
id: 'u_last',
|
||||
role: 'user',
|
||||
content: 'look at this',
|
||||
timestamp: 3,
|
||||
attachments: [
|
||||
{ base64Data: 'QUJD', mediaType: 'text/plain', filename: 'notes.txt', lineCount: 3 },
|
||||
],
|
||||
},
|
||||
]);
|
||||
const result = buildUndoLastTurnBranch(source, { newId: 'chat-2', now: 99 });
|
||||
assert.ok(result);
|
||||
assert.deepEqual(result.restored.attachments, [
|
||||
{
|
||||
id: 'undo_99_0',
|
||||
filename: 'notes.txt',
|
||||
mediaType: 'text/plain',
|
||||
base64Data: 'QUJD',
|
||||
dataUrl: 'data:text/plain;base64,QUJD',
|
||||
lineCount: 3,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test('buildUndoLastTurnBranch restores the undone turn selected user skills', () => {
|
||||
const source = session([
|
||||
user('first'),
|
||||
assistant('answer'),
|
||||
{ ...user('undo me'), selectedUserSkillSlugs: ['web-search', 'notes-writer'] },
|
||||
assistant('bad answer'),
|
||||
]);
|
||||
const result = buildUndoLastTurnBranch(source, { newId: 'chat-2', now: 42 });
|
||||
assert.ok(result);
|
||||
assert.deepEqual(result.restored.selectedUserSkillSlugs, ['web-search', 'notes-writer']);
|
||||
// Original session untouched (non-destructive).
|
||||
assert.deepEqual(source.messages[2]?.selectedUserSkillSlugs, ['web-search', 'notes-writer']);
|
||||
});
|
||||
|
||||
test('buildUndoLastTurnBranch restores no skill slugs for a plain turn', () => {
|
||||
const source = session([user('first'), assistant('answer'), user('undo me'), assistant('ok')]);
|
||||
const result = buildUndoLastTurnBranch(source, { newId: 'chat-2', now: 42 });
|
||||
assert.ok(result);
|
||||
assert.deepEqual(result.restored.selectedUserSkillSlugs, []);
|
||||
});
|
||||
|
||||
test('buildUndoLastTurnBranch returns null when no undo boundary exists', () => {
|
||||
assert.equal(
|
||||
buildUndoLastTurnBranch(session([assistant('only answer')]), { newId: 'x', now: 1 }),
|
||||
null,
|
||||
);
|
||||
});
|
||||
|
||||
test('buildUndoLastTurnBranch resets the title when undo removes the first turn', () => {
|
||||
// Undoing the first turn leaves the branch with no user message at all, so
|
||||
// the inherited title was derived from the removed prompt and would stay
|
||||
// stale forever (auto-titling only runs for untitled/New Chat sessions).
|
||||
const source = session([user('first prompt'), assistant('reply')], { title: 'first prompt' });
|
||||
const result = buildUndoLastTurnBranch(source, { newId: 'chat-branch', now: 100 });
|
||||
assert.ok(result);
|
||||
assert.equal(result?.session.messages.length, 0);
|
||||
assert.equal(result?.restored.text, 'first prompt');
|
||||
assert.equal(result?.session.title, 'New Chat');
|
||||
});
|
||||
|
||||
test('buildUndoLastTurnBranch keeps the title when the retained user turn is note-only', () => {
|
||||
// A note-only send persists empty user text while the mentioned note rides
|
||||
// on the attachment (its title seeded the conversation title), so a
|
||||
// blank-content retained user turn must still count as "a user message is
|
||||
// kept" and must not reset the title to the untitled placeholder.
|
||||
const noteOnlyUser: ChatMessage = {
|
||||
id: 'u_note-only',
|
||||
role: 'user',
|
||||
content: '',
|
||||
timestamp: 3,
|
||||
attachments: [{
|
||||
base64Data: '',
|
||||
mediaType: 'text/markdown',
|
||||
filename: 'plan.md',
|
||||
vaultNoteId: 'note-1',
|
||||
vaultNoteTitle: 'Deploy plan',
|
||||
previewText: '# Deploy plan',
|
||||
}],
|
||||
};
|
||||
const source = session(
|
||||
[noteOnlyUser, assistant('reply'), user('second prompt'), assistant('reply2')],
|
||||
{ title: 'Deploy plan' },
|
||||
);
|
||||
const result = buildUndoLastTurnBranch(source, { newId: 'chat-branch', now: 100 });
|
||||
assert.ok(result);
|
||||
assert.equal(result?.session.title, 'Deploy plan');
|
||||
});
|
||||
|
||||
test('buildUndoLastTurnBranch keeps the title when a user message is retained', () => {
|
||||
const source = session(
|
||||
[user('first prompt'), assistant('reply'), user('second prompt'), assistant('reply2')],
|
||||
{ title: 'renamed by user' },
|
||||
);
|
||||
const result = buildUndoLastTurnBranch(source, { newId: 'chat-branch', now: 100 });
|
||||
assert.ok(result);
|
||||
assert.equal(result?.session.messages.length, 2);
|
||||
assert.equal(result?.restored.text, 'second prompt');
|
||||
// The retained prefix still contains a user message, so the source title
|
||||
// keeps describing the branch's conversation.
|
||||
assert.equal(result?.session.title, 'renamed by user');
|
||||
});
|
||||
|
||||
test('undo allows a failed last reply while retaining portable replay metadata', () => {
|
||||
const continuation = { reasoningParts: [{ text: 'checked', providerOptions: { anthropic: { signature: 'signed-thinking' } } }] };
|
||||
const source = session([
|
||||
user('first'),
|
||||
assistant('first answer', { providerContinuation: continuation }),
|
||||
user('retry this request'),
|
||||
assistant('request failed', { executionStatus: 'failed' }),
|
||||
], { externalSessionId: 'original-provider-thread' });
|
||||
const result = buildUndoLastTurnBranch(source, { newId: 'branch', now: 99 });
|
||||
assert.ok(result);
|
||||
assert.deepEqual(result.session.messages[1].providerContinuation, continuation);
|
||||
assert.equal(result.session.externalSessionId, undefined);
|
||||
assert.equal(result.restored.text, 'retry this request');
|
||||
assert.equal(source.messages.length, 4);
|
||||
});
|
||||
|
||||
test('safe boundary does not pair a reused call ID with an earlier result', () => {
|
||||
const prefix = [
|
||||
assistant('first call', { toolCalls: [{ id: 'reused', name: 'terminal_execute', arguments: {} }] }),
|
||||
assistant('first result', { toolResults: [{ toolCallId: 'reused', content: 'done' }] }),
|
||||
assistant('second call', { toolCalls: [{ id: 'reused', name: 'terminal_execute', arguments: {} }] }),
|
||||
];
|
||||
assert.equal(resolveUndoLastTurnBoundary([...prefix, user('latest')]), null);
|
||||
assert.equal(resolveUndoLastTurnBoundary([
|
||||
...prefix,
|
||||
assistant('second result', { toolResults: [{ toolCallId: 'reused', content: 'done again' }] }),
|
||||
user('latest'),
|
||||
]), 4);
|
||||
});
|
||||
@@ -1,154 +0,0 @@
|
||||
import type {
|
||||
AISession,
|
||||
ChatMessage,
|
||||
ChatMessageAttachment,
|
||||
UploadedFile,
|
||||
} from '../infrastructure/ai/types';
|
||||
|
||||
/**
|
||||
* Non-destructive "undo last turn" for AI chat sessions.
|
||||
*
|
||||
* Undo branches the conversation at the boundary *before* the latest user
|
||||
* message instead of truncating the original session. The original session
|
||||
* stays in history untouched; the branch (without the last turn) becomes the
|
||||
* conversation the user continues, and the undone user message is restored
|
||||
* into the composer for editing and resending.
|
||||
*/
|
||||
|
||||
export interface UndoLastTurnRestoredDraft {
|
||||
text: string;
|
||||
attachments: UploadedFile[];
|
||||
selectedUserSkillSlugs: string[];
|
||||
}
|
||||
|
||||
export interface UndoLastTurnResult {
|
||||
/** Branched copy of the source session without the last user turn. */
|
||||
session: AISession;
|
||||
/** Undone user message content for the composer. */
|
||||
restored: UndoLastTurnRestoredDraft;
|
||||
}
|
||||
|
||||
/**
|
||||
* True when any assistant tool call in `messages` has no matching tool result
|
||||
* inside the same list. Cutting between a tool call and its result would
|
||||
* desync agent tool bookkeeping, so such boundaries are rejected.
|
||||
*/
|
||||
export function hasUnresolvedToolCalls(messages: readonly ChatMessage[]): boolean {
|
||||
// Providers may reuse tool-call IDs across a session. Pair each result with
|
||||
// the nearest preceding unresolved call carrying the same ID (same strategy
|
||||
// as `repairToolMessageIntegrity`), otherwise a result for an earlier
|
||||
// occurrence would satisfy a later call that reused the ID.
|
||||
const pendingCalls = new Map<string, number>();
|
||||
for (const message of messages) {
|
||||
for (const call of message.toolCalls ?? []) {
|
||||
pendingCalls.set(call.id, (pendingCalls.get(call.id) ?? 0) + 1);
|
||||
}
|
||||
for (const result of message.toolResults ?? []) {
|
||||
const pending = pendingCalls.get(result.toolCallId) ?? 0;
|
||||
if (pending > 0) pendingCalls.set(result.toolCallId, pending - 1);
|
||||
}
|
||||
}
|
||||
for (const pending of pendingCalls.values()) {
|
||||
if (pending > 0) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Index of the cut point for undoing the last turn (the last user message).
|
||||
* Returns null when undo is not offered:
|
||||
* - there is no user message at all,
|
||||
* - the boundary would fall inside the compacted prefix (its summary would no
|
||||
* longer match the kept messages),
|
||||
* - cutting there would split an assistant tool call from its result.
|
||||
*/
|
||||
export function resolveUndoLastTurnBoundary(
|
||||
messages: readonly ChatMessage[],
|
||||
compactedMessageCount = 0,
|
||||
): number | null {
|
||||
let lastUserIndex = -1;
|
||||
for (let i = messages.length - 1; i >= 0; i -= 1) {
|
||||
if (messages[i].role === 'user') {
|
||||
lastUserIndex = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (lastUserIndex < 0) return null;
|
||||
if (lastUserIndex < compactedMessageCount) return null;
|
||||
const keptPrefix = messages.slice(0, lastUserIndex);
|
||||
if (hasUnresolvedToolCalls(keptPrefix)) return null;
|
||||
return lastUserIndex;
|
||||
}
|
||||
|
||||
/** Map a persisted message attachment back to a composer upload (by value). */
|
||||
export function buildRestoredAttachments(
|
||||
message: ChatMessage,
|
||||
now: number,
|
||||
): UploadedFile[] {
|
||||
const attachments: ChatMessageAttachment[] = message.attachments ?? message.images ?? [];
|
||||
return attachments.map((attachment, index) => ({
|
||||
id: `undo_${now}_${index}`,
|
||||
filename: attachment.filename ?? '',
|
||||
mediaType: attachment.mediaType,
|
||||
base64Data: attachment.base64Data,
|
||||
dataUrl: attachment.base64Data
|
||||
? `data:${attachment.mediaType};base64,${attachment.base64Data}`
|
||||
: '',
|
||||
...(attachment.filePath !== undefined ? { filePath: attachment.filePath } : {}),
|
||||
...(attachment.terminalSelection ? { terminalSelection: true } : {}),
|
||||
...(attachment.vaultNoteId !== undefined ? { vaultNoteId: attachment.vaultNoteId } : {}),
|
||||
...(attachment.vaultNoteTitle !== undefined ? { vaultNoteTitle: attachment.vaultNoteTitle } : {}),
|
||||
...(attachment.previewText !== undefined ? { previewText: attachment.previewText } : {}),
|
||||
...(attachment.lineCount !== undefined ? { lineCount: attachment.lineCount } : {}),
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the branched "undo last turn" copy of a session.
|
||||
*
|
||||
* The branch keeps the compacted prefix intact (its summary still describes
|
||||
* the kept messages) and drops the external agent session id: external agents
|
||||
* resume provider-side sessions, so the branch must start a fresh one.
|
||||
*/
|
||||
export function buildUndoLastTurnBranch(
|
||||
source: AISession,
|
||||
options: { newId: string; now: number },
|
||||
): UndoLastTurnResult | null {
|
||||
const compactedMessageCount = source.contextCompaction?.compactedMessageCount ?? 0;
|
||||
const boundary = resolveUndoLastTurnBoundary(source.messages, compactedMessageCount);
|
||||
if (boundary == null) return null;
|
||||
|
||||
const removedUserMessage = source.messages[boundary];
|
||||
const retainedPrefix = source.messages.slice(0, boundary);
|
||||
// When undo removes the first turn, the branch has no user message left and
|
||||
// still carries the source title, which was auto-titled from the removed
|
||||
// prompt. Reset it to the untitled placeholder so `autoTitleSession` can
|
||||
// retitle the branch from the prompt the user sends next; otherwise the
|
||||
// branch is permanently labeled with text that is no longer its prompt.
|
||||
// A retained user turn can legitimately carry empty text: a note-only send
|
||||
// leaves `content` blank while the mentioned note rides on the attachment
|
||||
// (and its title) — that turn still anchored the conversation, so it must
|
||||
// keep the note-derived title and suppress the reset.
|
||||
const hasRetainedUserMessage = retainedPrefix.some(
|
||||
message => message.role === 'user',
|
||||
);
|
||||
const branchedSession: AISession = {
|
||||
...source,
|
||||
id: options.newId,
|
||||
messages: retainedPrefix,
|
||||
...(hasRetainedUserMessage ? {} : { title: 'New Chat' }),
|
||||
externalSessionId: undefined,
|
||||
createdAt: options.now,
|
||||
updatedAt: options.now,
|
||||
};
|
||||
return {
|
||||
session: branchedSession,
|
||||
restored: {
|
||||
text: removedUserMessage.content,
|
||||
attachments: buildRestoredAttachments(removedUserMessage, options.now),
|
||||
// The undone turn was selected with these skill pills: they shaped the
|
||||
// model context, so resending the turn must not silently omit them.
|
||||
selectedUserSkillSlugs: [...(removedUserMessage.selectedUserSkillSlugs ?? [])],
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -59,12 +59,6 @@ test("applyGroupDefaults lets host device type override group device type", () =
|
||||
assert.equal(result.deviceType, "general");
|
||||
});
|
||||
|
||||
test("applyGroupDefaults skips remote-only device type for local shell hosts", () => {
|
||||
const result = applyGroupDefaults(host({ protocol: "local" }), { deviceType: "network" });
|
||||
|
||||
assert.equal(result.deviceType, undefined);
|
||||
});
|
||||
|
||||
test("applyGroupDefaults inherits startup command run mode", () => {
|
||||
const result = applyGroupDefaults(host(), { startupCommandRunMode: "paste" });
|
||||
|
||||
@@ -644,87 +638,3 @@ test("applyGroupDefaults still inherits non-credential defaults into serial host
|
||||
assert.equal(result.username, undefined);
|
||||
assert.equal(result.charset, "UTF-8");
|
||||
});
|
||||
|
||||
test("applyGroupDefaults does not inherit group SSH credentials into local shell hosts", () => {
|
||||
const groupDefaults: Partial<GroupConfig> = {
|
||||
username: "group-ssh-user",
|
||||
password: "group-ssh-password",
|
||||
savePassword: true,
|
||||
authMethod: "password",
|
||||
identityId: "identity-1",
|
||||
identityFileId: "identity-file-1",
|
||||
};
|
||||
|
||||
const result = applyGroupDefaults(
|
||||
host({ protocol: "local", username: undefined, password: undefined }),
|
||||
groupDefaults,
|
||||
);
|
||||
|
||||
assert.equal(result.username, undefined);
|
||||
assert.equal(result.password, undefined);
|
||||
assert.equal(result.savePassword, undefined);
|
||||
assert.equal(result.authMethod, undefined);
|
||||
assert.equal(result.identityId, undefined);
|
||||
assert.equal(result.identityFileId, undefined);
|
||||
});
|
||||
|
||||
test("applyGroupDefaults does not inherit SSH transport/input defaults into local shell hosts", () => {
|
||||
const groupDefaults: Partial<GroupConfig> = {
|
||||
backspaceBehavior: "ctrl-h",
|
||||
moshEnabled: true,
|
||||
moshServerPath: "/usr/bin/mosh-server",
|
||||
etEnabled: true,
|
||||
etPort: 8737,
|
||||
legacyAlgorithms: true,
|
||||
skipEcdsaHostKey: true,
|
||||
agentForwarding: true,
|
||||
hostChain: { hostIds: ["bastion-1"] },
|
||||
charset: "UTF-8",
|
||||
fontSize: 14,
|
||||
};
|
||||
|
||||
const result = applyGroupDefaults(
|
||||
host({
|
||||
protocol: "local",
|
||||
backspaceBehavior: undefined,
|
||||
algorithms: undefined,
|
||||
charset: undefined,
|
||||
fontSize: undefined,
|
||||
}),
|
||||
groupDefaults,
|
||||
);
|
||||
|
||||
assert.equal(result.backspaceBehavior, undefined);
|
||||
assert.equal(result.moshEnabled, undefined);
|
||||
assert.equal(result.moshServerPath, undefined);
|
||||
assert.equal(result.etEnabled, undefined);
|
||||
assert.equal(result.etPort, undefined);
|
||||
assert.equal(result.legacyAlgorithms, undefined);
|
||||
assert.equal(result.skipEcdsaHostKey, undefined);
|
||||
assert.equal(result.agentForwarding, undefined);
|
||||
assert.equal(result.hostChain, undefined);
|
||||
// Display/appearance defaults still apply to local shells.
|
||||
assert.equal(result.charset, "UTF-8");
|
||||
assert.equal(result.fontSize, 14);
|
||||
});
|
||||
|
||||
test("applyGroupDefaults still inherits non-credential defaults into local shell hosts", () => {
|
||||
const result = applyGroupDefaults(
|
||||
host({ protocol: "local", username: undefined, charset: undefined, fontSize: undefined }),
|
||||
{ username: "group-ssh-user", charset: "UTF-8", fontSize: 14 },
|
||||
);
|
||||
|
||||
assert.equal(result.username, undefined);
|
||||
assert.equal(result.charset, "UTF-8");
|
||||
assert.equal(result.fontSize, 14);
|
||||
});
|
||||
|
||||
test("applyGroupDefaults does not inherit the group startup command into local shell hosts", () => {
|
||||
const result = applyGroupDefaults(
|
||||
host({ protocol: "local" }),
|
||||
{ startupCommand: "rm -rf /", startupCommandRunMode: "paste" },
|
||||
);
|
||||
|
||||
assert.equal(result.startupCommand, undefined);
|
||||
assert.equal(result.startupCommandRunMode, "paste");
|
||||
});
|
||||
@@ -176,25 +176,6 @@ const SSH_CREDENTIAL_KEYS = new Set<keyof GroupConfig>([
|
||||
'identityFilePaths',
|
||||
]);
|
||||
|
||||
// SSH transport/input settings the local-shell editor does not expose: input
|
||||
// remapping (backspace), Mosh/Eternal-Terminal transports, and SSH-only
|
||||
// algorithm/agent/chain flags. A local shell never runs through SSH, so these
|
||||
// defaults would either remap local PTY input (`backspaceBehavior`,
|
||||
// createXTermRuntime) or make flag-first consumers report a purely local
|
||||
// session as Mosh/ET — with no way to override them in the local editor.
|
||||
const SSH_ONLY_TRANSPORT_KEYS = new Set<keyof GroupConfig>([
|
||||
'agentForwarding',
|
||||
'legacyAlgorithms',
|
||||
'skipEcdsaHostKey',
|
||||
'algorithms',
|
||||
'hostChain',
|
||||
'moshEnabled',
|
||||
'moshServerPath',
|
||||
'etEnabled',
|
||||
'etPort',
|
||||
'backspaceBehavior',
|
||||
]);
|
||||
|
||||
/**
|
||||
* Apply group defaults to a host. Only fills in fields the host doesn't already have.
|
||||
* Returns a new host object — does NOT mutate the original.
|
||||
@@ -236,21 +217,9 @@ export function applyGroupDefaults(
|
||||
// group's SSH password to anything that prints a login prompt.
|
||||
const isSerialHost = host.protocol === 'serial';
|
||||
|
||||
// Local-shell hosts run on the user's own machine. Group defaults are
|
||||
// SSH/remote-oriented: the SSH credential bundle and a saved startup
|
||||
// command were written for the group's remote hosts and must never be
|
||||
// inherited into a local shell, or clicking a local entry would
|
||||
// type/execute that command on the local machine — and "Copy Credentials"
|
||||
// on a local shell would copy an unrelated remote password. `deviceType`
|
||||
// is equally remote-only: a saved "network" device type would make a
|
||||
// local CMD/PowerShell/POSIX shell be treated as a network-device CLI
|
||||
// (no normal autocomplete, network semantics for AI/command execution).
|
||||
const isLocalShellHost = host.protocol === 'local';
|
||||
|
||||
for (const key of INHERITABLE_KEYS) {
|
||||
if (shouldSkipGroupSshCredentialBundle && SSH_CREDENTIAL_KEYS.has(key)) continue;
|
||||
if (isSerialHost && SSH_CREDENTIAL_KEYS.has(key)) continue;
|
||||
if (isLocalShellHost && (SSH_CREDENTIAL_KEYS.has(key) || SSH_ONLY_TRANSPORT_KEYS.has(key) || key === 'startupCommand' || key === 'deviceType')) continue;
|
||||
if (key === 'password' && effective.savePassword === false) continue;
|
||||
if (key === 'telnetIdentityId' && hostHasManualTelnetCredentials) continue;
|
||||
if (key === 'proxyProfileId') {
|
||||
|
||||
@@ -1,31 +0,0 @@
|
||||
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);
|
||||
});
|
||||
@@ -1,14 +0,0 @@
|
||||
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;
|
||||
}
|
||||
@@ -1,94 +0,0 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { test } from 'node:test';
|
||||
import {
|
||||
createLocalShellHost,
|
||||
getLocalShellHostSubtitle,
|
||||
getVaultHostRowSubtitle,
|
||||
resolveHostDefaultLocalShell,
|
||||
} from './localShellHost';
|
||||
|
||||
test('createLocalShellHost builds a grouped local-protocol host', () => {
|
||||
const host = createLocalShellHost({
|
||||
label: 'Project A CMD',
|
||||
shell: 'powershell',
|
||||
shellName: 'Windows PowerShell',
|
||||
shellIcon: 'powershell',
|
||||
group: 'Projects/A',
|
||||
startDir: ' /home/user/work ',
|
||||
tags: ['local', 'build'],
|
||||
});
|
||||
|
||||
assert.equal(host.protocol, 'local');
|
||||
assert.equal(host.hostname, 'localhost');
|
||||
assert.equal(host.username, '');
|
||||
assert.equal(host.label, 'Project A CMD');
|
||||
assert.equal(host.group, 'Projects/A');
|
||||
assert.equal(host.localShell, 'powershell');
|
||||
assert.equal(host.localShellName, 'Windows PowerShell');
|
||||
assert.equal(host.localShellIcon, 'powershell');
|
||||
assert.equal(host.localStartDir, '/home/user/work');
|
||||
assert.deepEqual(host.tags, ['local', 'build']);
|
||||
assert.ok(host.id.startsWith('local-'));
|
||||
});
|
||||
|
||||
test('createLocalShellHost falls back to defaults for empty input', () => {
|
||||
const host = createLocalShellHost({});
|
||||
assert.equal(host.label, 'Local Shell');
|
||||
assert.equal(host.localShell, undefined);
|
||||
assert.equal(host.localShellName, undefined);
|
||||
assert.equal(host.localStartDir, undefined);
|
||||
assert.deepEqual(host.tags, ['local']);
|
||||
assert.equal(host.group, '');
|
||||
assert.equal(host.os, 'linux');
|
||||
});
|
||||
|
||||
test('createLocalShellHost preserves explicit id when editing', () => {
|
||||
const host = createLocalShellHost({ id: 'local-existing', label: 'Renamed' });
|
||||
assert.equal(host.id, 'local-existing');
|
||||
});
|
||||
|
||||
test('getVaultHostRowSubtitle uses shell name for local hosts', () => {
|
||||
assert.equal(
|
||||
getVaultHostRowSubtitle({ protocol: 'local', localShellName: 'CMD', username: '', hostname: 'localhost' }),
|
||||
'CMD',
|
||||
);
|
||||
assert.equal(
|
||||
getVaultHostRowSubtitle({ protocol: 'local', username: '', hostname: 'localhost' }),
|
||||
'Local Shell',
|
||||
);
|
||||
assert.equal(
|
||||
getVaultHostRowSubtitle(
|
||||
{ protocol: 'local', username: '', hostname: 'localhost' },
|
||||
{ localFallback: '本地终端' },
|
||||
),
|
||||
'本地终端',
|
||||
);
|
||||
});
|
||||
|
||||
test('getVaultHostRowSubtitle keeps user@host for remote hosts', () => {
|
||||
assert.equal(
|
||||
getVaultHostRowSubtitle({ protocol: 'ssh', username: 'root', hostname: 'example.com' }),
|
||||
'root@example.com',
|
||||
);
|
||||
assert.equal(
|
||||
getVaultHostRowSubtitle({ protocol: 'telnet', username: '', hostname: '10.0.0.1' }),
|
||||
'@10.0.0.1',
|
||||
);
|
||||
});
|
||||
|
||||
test('getLocalShellHostSubtitle ignores whitespace-only names and allows a fallback', () => {
|
||||
assert.equal(getLocalShellHostSubtitle({ protocol: 'local', localShellName: ' ' }), 'Local Shell');
|
||||
assert.equal(
|
||||
getLocalShellHostSubtitle({ protocol: 'local', localShellName: '' }, '本地终端'),
|
||||
'本地终端',
|
||||
);
|
||||
});
|
||||
|
||||
test('clearing the configured executable does not pass its old custom args to the backend default', () => {
|
||||
const effective = resolveHostDefaultLocalShell(createLocalShellHost({}), {
|
||||
terminalSettings: { localShell: '', localShellArgs: ['--norc'] },
|
||||
resolveShellSetting: () => null,
|
||||
});
|
||||
assert.equal(effective.localShell, '');
|
||||
assert.equal(effective.localShellArgs, undefined);
|
||||
});
|
||||
@@ -1,107 +0,0 @@
|
||||
import type { Host } from './models';
|
||||
|
||||
/**
|
||||
* Vault-side "local shell" (CMD / PowerShell / WSL / custom shell) host entry.
|
||||
*
|
||||
* A vault host with protocol 'local' is grouped, sorted and connected exactly
|
||||
* like any other saved host: clicking it opens a terminal session whose boot
|
||||
* path resolves the host's stored shell (see `startLocal` in
|
||||
* `createTerminalSessionStarters` and the bridge resolution of discovered
|
||||
* shell ids).
|
||||
*/
|
||||
|
||||
export type LocalShellHostOs = 'linux' | 'windows' | 'macos';
|
||||
|
||||
export interface LocalShellHostInput {
|
||||
id?: string;
|
||||
os?: LocalShellHostOs;
|
||||
label?: string;
|
||||
/** Discovered shell id (e.g. "wsl-ubuntu") or a custom shell path/command. Empty = system default. */
|
||||
shell?: string;
|
||||
shellArgs?: string[];
|
||||
shellName?: string;
|
||||
shellIcon?: string;
|
||||
startDir?: string;
|
||||
group?: string;
|
||||
tags?: string[];
|
||||
notes?: string;
|
||||
}
|
||||
|
||||
export const LOCAL_SHELL_HOST_TAG = 'local';
|
||||
|
||||
export const createLocalShellHost = (input: LocalShellHostInput): Host => ({
|
||||
id: input.id ?? `local-${Date.now()}-${Math.random().toString(36).substring(2, 11)}`,
|
||||
label: input.label?.trim() || input.shellName?.trim() || 'Local Shell',
|
||||
hostname: 'localhost',
|
||||
username: '',
|
||||
tags: input.tags ?? [LOCAL_SHELL_HOST_TAG],
|
||||
group: input.group?.trim() || '',
|
||||
os: input.os ?? 'linux',
|
||||
protocol: 'local',
|
||||
createdAt: Date.now(),
|
||||
notes: input.notes?.trim() || undefined,
|
||||
localShell: input.shell?.trim() || undefined,
|
||||
localShellArgs: input.shellArgs?.length ? input.shellArgs : undefined,
|
||||
localShellName: input.shellName?.trim() || undefined,
|
||||
localShellIcon: input.shellIcon?.trim() || undefined,
|
||||
localStartDir: input.startDir?.trim() || undefined,
|
||||
});
|
||||
|
||||
/** What an existing vault host shows as its local-shell display name. */
|
||||
export const getLocalShellHostSubtitle = (
|
||||
host: Pick<Host, 'protocol' | 'localShellName'>,
|
||||
fallback = 'Local Shell',
|
||||
): string => host.localShellName?.trim() || fallback;
|
||||
|
||||
export interface VaultHostRowSubtitleOptions {
|
||||
/** Translated fallback shown for default-shell local hosts. */
|
||||
localFallback?: string;
|
||||
}
|
||||
|
||||
/** Row subtitle shared by the vault tree rows and host cards. */
|
||||
export const getVaultHostRowSubtitle = (
|
||||
host: Pick<Host, 'protocol' | 'localShellName' | 'username' | 'hostname'>,
|
||||
options?: VaultHostRowSubtitleOptions,
|
||||
): string => (
|
||||
host.protocol === 'local'
|
||||
? getLocalShellHostSubtitle(host, options?.localFallback)
|
||||
: `${host.username ?? ''}@${host.hostname ?? ''}`
|
||||
);
|
||||
|
||||
export interface ResolveDefaultLocalShellContext {
|
||||
discoveredShells?: DiscoveredShell[];
|
||||
terminalSettings?: { localShell?: string; localShellArgs?: string[]; localStartDir?: string };
|
||||
resolveShellSetting?: (
|
||||
localShell: string,
|
||||
discoveredShells: DiscoveredShell[],
|
||||
customArgs?: string[],
|
||||
) => { command: string; args?: string[] } | null;
|
||||
}
|
||||
|
||||
/** Resolve launch settings before a local session snapshots them. */
|
||||
export const resolveHostDefaultLocalShell = (
|
||||
host: Host,
|
||||
ctx: ResolveDefaultLocalShellContext,
|
||||
): Host => {
|
||||
if (host.protocol !== 'local') return host;
|
||||
const configuredShell = host.localShell || ctx.terminalSettings?.localShell || '';
|
||||
const customArgs = host.localShell
|
||||
? host.localShellArgs
|
||||
: configuredShell ? ctx.terminalSettings?.localShellArgs : undefined;
|
||||
const resolved = ctx.resolveShellSetting?.(
|
||||
configuredShell, ctx.discoveredShells ?? [], customArgs,
|
||||
);
|
||||
const matchedShell = ctx.discoveredShells?.find((shell) => shell.id === configuredShell);
|
||||
return {
|
||||
...host,
|
||||
// Empty means the backend default, not a later Vault/Settings value.
|
||||
localShell: resolved?.command || configuredShell || '',
|
||||
localShellArgs: resolved?.args ?? customArgs,
|
||||
localShellName: host.localShell ? host.localShellName : matchedShell?.name,
|
||||
localShellIcon: host.localShell ? host.localShellIcon : matchedShell?.icon,
|
||||
// The existing launcher expands ~ to home. Capture it explicitly when
|
||||
// neither host nor Settings supplies a directory, so a later global edit
|
||||
// cannot change a copied session's original launch directory.
|
||||
localStartDir: host.localStartDir || ctx.terminalSettings?.localStartDir || '~',
|
||||
};
|
||||
};
|
||||
@@ -864,32 +864,3 @@ test("resolveInheritedCwdIntent: skips mosh/et and network devices and bad paths
|
||||
test("resolveInheritedCwdIntent: skips windows local shells", () => {
|
||||
assert.equal(resolveInheritedCwdIntent({ session: { protocol: "local", shellType: "powershell", cwd: "/x" }, isNetworkDevice: false }), null);
|
||||
});
|
||||
|
||||
test("restore retains an explicit backend-default local shell snapshot", () => {
|
||||
const payload = buildSessionRestorePayload({
|
||||
sessions: [{ ...session("local-default"), protocol: "local", localShell: "", localShellArgs: [], localStartDir: "~" }],
|
||||
workspaces: [], tabOrder: [], activeTabId: "local-default", now: 123,
|
||||
});
|
||||
const restored = sanitizeSessionRestorePayload(JSON.parse(JSON.stringify(payload)));
|
||||
assert.equal(restored.sessions[0].localShell, "");
|
||||
assert.deepEqual(restored.sessions[0].localShellArgs, []);
|
||||
assert.equal(restored.sessions[0].localStartDir, "~");
|
||||
});
|
||||
|
||||
test("restore retains absent local display snapshots while legacy fields remain absent", () => {
|
||||
const payload = buildSessionRestorePayload({
|
||||
sessions: [
|
||||
{ ...session("empty-display"), protocol: "local", localShellName: "", localShellIcon: "" },
|
||||
{ ...session("named-display"), protocol: "local", localShellName: "Bash", localShellIcon: "bash" },
|
||||
{ ...session("legacy-display"), protocol: "local" },
|
||||
],
|
||||
workspaces: [], tabOrder: [], activeTabId: "empty-display", now: 123,
|
||||
});
|
||||
const restored = sanitizeSessionRestorePayload(JSON.parse(JSON.stringify(payload)));
|
||||
assert.equal(restored.sessions[0].localShellName, "");
|
||||
assert.equal(restored.sessions[0].localShellIcon, "");
|
||||
assert.equal(restored.sessions[1].localShellName, "Bash");
|
||||
assert.equal(restored.sessions[1].localShellIcon, "bash");
|
||||
assert.equal(restored.sessions[2].localShellName, undefined);
|
||||
assert.equal(restored.sessions[2].localShellIcon, undefined);
|
||||
});
|
||||
@@ -152,10 +152,10 @@ const restoreSession = (session: TerminalSession): RestoredTerminalSession => {
|
||||
...(shellType ? { shellType } : {}),
|
||||
...(session.charset ? { charset: session.charset } : {}),
|
||||
...(serialConfig ? { serialConfig } : {}),
|
||||
...(session.localShell !== undefined ? { localShell: session.localShell } : {}),
|
||||
...(session.localShell ? { localShell: session.localShell } : {}),
|
||||
...(session.localShellArgs ? { localShellArgs: [...session.localShellArgs] } : {}),
|
||||
...(session.localShellName !== undefined ? { localShellName: session.localShellName } : {}),
|
||||
...(session.localShellIcon !== undefined ? { localShellIcon: session.localShellIcon } : {}),
|
||||
...(session.localShellName ? { localShellName: session.localShellName } : {}),
|
||||
...(session.localShellIcon ? { localShellIcon: session.localShellIcon } : {}),
|
||||
...(session.localStartDir ? { localStartDir: session.localStartDir } : {}),
|
||||
...(session.fontSize !== undefined ? { fontSize: session.fontSize } : {}),
|
||||
...(session.fontSizeOverride !== undefined ? { fontSizeOverride: session.fontSizeOverride } : {}),
|
||||
@@ -195,10 +195,10 @@ const restoreSessionFromUnknown = (value: unknown): RestoredTerminalSession | nu
|
||||
...(shellType ? { shellType } : {}),
|
||||
...(readString(value, "charset") ? { charset: readString(value, "charset") } : {}),
|
||||
...(serialConfig ? { serialConfig } : {}),
|
||||
...(readStringAllowEmpty(value, "localShell") !== undefined ? { localShell: readStringAllowEmpty(value, "localShell") } : {}),
|
||||
...(readString(value, "localShell") ? { localShell: readString(value, "localShell") } : {}),
|
||||
...(Array.isArray(value.localShellArgs) ? { localShellArgs: value.localShellArgs.filter((arg): arg is string => typeof arg === "string") } : {}),
|
||||
...(readStringAllowEmpty(value, "localShellName") !== undefined ? { localShellName: readStringAllowEmpty(value, "localShellName") } : {}),
|
||||
...(readStringAllowEmpty(value, "localShellIcon") !== undefined ? { localShellIcon: readStringAllowEmpty(value, "localShellIcon") } : {}),
|
||||
...(readString(value, "localShellName") ? { localShellName: readString(value, "localShellName") } : {}),
|
||||
...(readString(value, "localShellIcon") ? { localShellIcon: readString(value, "localShellIcon") } : {}),
|
||||
...(readString(value, "localStartDir") ? { localStartDir: readString(value, "localStartDir") } : {}),
|
||||
...(readNumber(value, "fontSize") !== undefined ? { fontSize: readNumber(value, "fontSize") } : {}),
|
||||
...(readBoolean(value, "fontSizeOverride") !== undefined ? { fontSizeOverride: readBoolean(value, "fontSizeOverride") } : {}),
|
||||
|
||||
@@ -46,9 +46,6 @@ export type DualPaneSftpPlan = {
|
||||
export function canOpenDualPaneSftp(host: { protocol?: string }): boolean {
|
||||
const protocol = host.protocol ?? "ssh";
|
||||
if (protocol === "serial") return false;
|
||||
// Local-shell vault hosts have no SFTP/SSH channel: opening dual-pane SFTP
|
||||
// from one would try to SSH into localhost with empty credentials.
|
||||
if (protocol === "local") return false;
|
||||
if (isPluginHostProtocol(protocol)) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -173,79 +173,6 @@ test("resolveTerminalSessionHost keeps the session plugin configuration when the
|
||||
});
|
||||
});
|
||||
|
||||
test("resolveTerminalSessionHost preserves the session local-shell snapshot over an edited Vault host", () => {
|
||||
const host: Host = {
|
||||
id: "target",
|
||||
label: "Edited PowerShell",
|
||||
hostname: "localhost",
|
||||
username: "local",
|
||||
protocol: "local",
|
||||
tags: [],
|
||||
os: "windows",
|
||||
localShell: "powershell.exe",
|
||||
localShellArgs: ["-NoLogo"],
|
||||
localShellName: "PowerShell",
|
||||
localShellIcon: "powershell",
|
||||
localStartDir: "C:/edited",
|
||||
};
|
||||
|
||||
const resolved = resolveTerminalSessionHost({
|
||||
session: {
|
||||
...baseSession,
|
||||
hostname: "localhost",
|
||||
username: "local",
|
||||
port: undefined,
|
||||
protocol: "local",
|
||||
localShell: "cmd.exe",
|
||||
localShellArgs: [],
|
||||
localShellName: "Command Prompt",
|
||||
localShellIcon: "cmd",
|
||||
localStartDir: "C:/project",
|
||||
},
|
||||
hosts: [host],
|
||||
groupConfigs: [],
|
||||
proxyProfiles,
|
||||
localOs: "windows",
|
||||
});
|
||||
|
||||
assert.equal(resolved.localShell, "cmd.exe");
|
||||
assert.deepEqual(resolved.localShellArgs, []);
|
||||
assert.equal(resolved.localShellName, "Command Prompt");
|
||||
assert.equal(resolved.localShellIcon, "cmd");
|
||||
assert.equal(resolved.localStartDir, "C:/project");
|
||||
});
|
||||
|
||||
test("resolveTerminalSessionHost falls back to the saved local shell when the session has no snapshot", () => {
|
||||
const host: Host = {
|
||||
id: "target",
|
||||
label: "Local Shell",
|
||||
hostname: "localhost",
|
||||
username: "local",
|
||||
protocol: "local",
|
||||
tags: [],
|
||||
os: "windows",
|
||||
localShell: "powershell.exe",
|
||||
localShellName: "PowerShell",
|
||||
};
|
||||
|
||||
const resolved = resolveTerminalSessionHost({
|
||||
session: {
|
||||
...baseSession,
|
||||
hostname: "localhost",
|
||||
username: "local",
|
||||
port: undefined,
|
||||
protocol: "local",
|
||||
},
|
||||
hosts: [host],
|
||||
groupConfigs: [],
|
||||
proxyProfiles,
|
||||
localOs: "windows",
|
||||
});
|
||||
|
||||
assert.equal(resolved.localShell, "powershell.exe");
|
||||
assert.equal(resolved.localShellName, "PowerShell");
|
||||
});
|
||||
|
||||
test("resolveTerminalSessionHost carries local session start directory into fallback host", () => {
|
||||
const resolved = resolveTerminalSessionHost({
|
||||
session: {
|
||||
@@ -561,77 +488,3 @@ test("resolveTerminalChainHosts applies group default proxy profiles to jump hos
|
||||
assert.equal(resolved[0]?.proxyProfileId, "proxy-1");
|
||||
assert.deepEqual(resolved[0]?.proxyConfig, proxyProfiles[0].config);
|
||||
});
|
||||
|
||||
test("resolveTerminalChainHosts skips non-SSH jump candidates", () => {
|
||||
const target: Host = {
|
||||
id: "target",
|
||||
label: "Target",
|
||||
hostname: "target.example.test",
|
||||
username: "alice",
|
||||
port: 22,
|
||||
protocol: "ssh",
|
||||
tags: [],
|
||||
os: "linux",
|
||||
hostChain: { hostIds: ["jump-local", "jump-serial", "jump-telnet", "jump-ssh", "jump-mosh"] },
|
||||
};
|
||||
const localHost: Host = {
|
||||
id: "jump-local",
|
||||
label: "Local Shell",
|
||||
hostname: "localhost",
|
||||
username: "",
|
||||
port: 22,
|
||||
protocol: "local",
|
||||
tags: [],
|
||||
os: "linux",
|
||||
};
|
||||
const serialHost: Host = {
|
||||
id: "jump-serial",
|
||||
label: "Serial",
|
||||
hostname: "/dev/ttyUSB0",
|
||||
username: "",
|
||||
port: 22,
|
||||
protocol: "serial",
|
||||
tags: [],
|
||||
os: "linux",
|
||||
};
|
||||
const telnetHost: Host = {
|
||||
id: "jump-telnet",
|
||||
label: "Telnet",
|
||||
hostname: "telnet.example.test",
|
||||
username: "bob",
|
||||
port: 23,
|
||||
protocol: "telnet",
|
||||
tags: [],
|
||||
os: "linux",
|
||||
};
|
||||
const sshHost: Host = {
|
||||
id: "jump-ssh",
|
||||
label: "Jump SSH",
|
||||
hostname: "jump.example.test",
|
||||
username: "jump",
|
||||
port: 22,
|
||||
protocol: "ssh",
|
||||
tags: [],
|
||||
os: "linux",
|
||||
};
|
||||
const moshHost: Host = {
|
||||
id: "jump-mosh",
|
||||
label: "Jump Mosh",
|
||||
hostname: "mosh.example.test",
|
||||
username: "jump",
|
||||
port: 22,
|
||||
protocol: "ssh",
|
||||
moshEnabled: true,
|
||||
tags: [],
|
||||
os: "linux",
|
||||
};
|
||||
|
||||
const resolved = resolveTerminalChainHosts({
|
||||
host: target,
|
||||
hosts: [target, localHost, serialHost, telnetHost, sshHost, moshHost],
|
||||
groupConfigs: [],
|
||||
proxyProfiles,
|
||||
});
|
||||
|
||||
assert.deepEqual(resolved.map((host) => host.id), ["jump-ssh", "jump-mosh"]);
|
||||
});
|
||||
@@ -2,8 +2,6 @@ import type { GroupConfig, Host, ProxyProfile, TerminalSession } from "./models"
|
||||
import { applyGroupDefaults, resolveGroupDefaults } from "./groupConfig";
|
||||
import { materializeHostProxyProfile } from "./proxyProfiles";
|
||||
import { sanitizePluginConnection } from "./pluginConnection";
|
||||
import { resolveHostDefaultLocalShell, type ResolveDefaultLocalShellContext } from "./localShellHost";
|
||||
import { canServeAsSshJumpHost } from "./terminalProtocol";
|
||||
|
||||
type LocalOs = Host["os"];
|
||||
|
||||
@@ -12,8 +10,6 @@ interface ResolveEffectiveHostOptions {
|
||||
groupConfigs: GroupConfig[];
|
||||
proxyProfiles: ProxyProfile[];
|
||||
validProxyProfileIds?: ReadonlySet<string>;
|
||||
/** When provided, saved local hosts with no shell resolve the Settings shell. */
|
||||
defaultLocalShell?: ResolveDefaultLocalShellContext;
|
||||
}
|
||||
|
||||
interface ResolveTerminalSessionHostOptions {
|
||||
@@ -22,8 +18,6 @@ interface ResolveTerminalSessionHostOptions {
|
||||
groupConfigs: GroupConfig[];
|
||||
proxyProfiles: ProxyProfile[];
|
||||
localOs: LocalOs;
|
||||
/** When provided, saved local hosts with no shell resolve the Settings shell. */
|
||||
defaultLocalShell?: ResolveDefaultLocalShellContext;
|
||||
}
|
||||
|
||||
interface ResolveTerminalChainHostsOptions {
|
||||
@@ -47,18 +41,14 @@ export function resolveEffectiveTerminalHost({
|
||||
groupConfigs,
|
||||
proxyProfiles,
|
||||
validProxyProfileIds = new Set(proxyProfiles.map((profile) => profile.id)),
|
||||
defaultLocalShell,
|
||||
}: ResolveEffectiveHostOptions): Host {
|
||||
const groupDefaults = host.group
|
||||
? resolveGroupDefaults(host.group, groupConfigs, { validProxyProfileIds })
|
||||
: {};
|
||||
const effectiveHost = materializeHostProxyProfile(
|
||||
return materializeHostProxyProfile(
|
||||
applyGroupDefaults(host, groupDefaults, { validProxyProfileIds }),
|
||||
proxyProfiles,
|
||||
);
|
||||
return defaultLocalShell
|
||||
? resolveHostDefaultLocalShell(effectiveHost, defaultLocalShell)
|
||||
: effectiveHost;
|
||||
}
|
||||
|
||||
const suppressDeviceTypeForShellTransport = (host: Host): Host => {
|
||||
@@ -96,30 +86,12 @@ function buildFallbackHostFromSession(
|
||||
};
|
||||
}
|
||||
|
||||
// A local session carries a shell snapshot (shell path, args, name, icon,
|
||||
// start dir) captured at launch time; the saved host may have been edited
|
||||
// since. Popup/clone flows must relaunch the session's shell, not the host's
|
||||
// current one, so overlay the snapshot onto the resolved host.
|
||||
const overlaySessionLocalShell = (session: TerminalSession, host: Host): Host => {
|
||||
if (session.protocol !== "local") return host;
|
||||
return {
|
||||
...host,
|
||||
username: session.username,
|
||||
localShell: session.localShell ?? host.localShell,
|
||||
localShellArgs: session.localShellArgs ?? host.localShellArgs,
|
||||
localShellName: session.localShellName ?? host.localShellName,
|
||||
localShellIcon: session.localShellIcon ?? host.localShellIcon,
|
||||
localStartDir: session.localStartDir ?? host.localStartDir,
|
||||
};
|
||||
};
|
||||
|
||||
export function resolveTerminalSessionHost({
|
||||
session,
|
||||
hosts,
|
||||
groupConfigs,
|
||||
proxyProfiles,
|
||||
localOs,
|
||||
defaultLocalShell,
|
||||
}: ResolveTerminalSessionHostOptions): Host {
|
||||
const vaultHost = hosts.find((host) => host.id === session.hostId);
|
||||
if (!vaultHost) return buildFallbackHostFromSession(session, localOs);
|
||||
@@ -128,7 +100,6 @@ export function resolveTerminalSessionHost({
|
||||
host: vaultHost,
|
||||
groupConfigs,
|
||||
proxyProfiles,
|
||||
defaultLocalShell,
|
||||
});
|
||||
|
||||
const protocol = session.protocol ?? existingHost.protocol;
|
||||
@@ -155,12 +126,10 @@ export function resolveTerminalSessionHost({
|
||||
JSON.stringify(pluginConnection) === JSON.stringify(existingHost.pluginConnection) &&
|
||||
backspaceBehavior === existingHost.backspaceBehavior
|
||||
) {
|
||||
return suppressDeviceTypeForShellTransport(
|
||||
overlaySessionLocalShell(session, existingHost),
|
||||
);
|
||||
return suppressDeviceTypeForShellTransport(existingHost);
|
||||
}
|
||||
|
||||
return suppressDeviceTypeForShellTransport(overlaySessionLocalShell(session, {
|
||||
return suppressDeviceTypeForShellTransport({
|
||||
...existingHost,
|
||||
protocol,
|
||||
port,
|
||||
@@ -168,7 +137,7 @@ export function resolveTerminalSessionHost({
|
||||
etEnabled,
|
||||
pluginConnection,
|
||||
backspaceBehavior,
|
||||
}));
|
||||
});
|
||||
}
|
||||
|
||||
export function resolveTerminalChainHosts({
|
||||
@@ -183,9 +152,7 @@ export function resolveTerminalChainHosts({
|
||||
return host.hostChain.hostIds
|
||||
.map((hostId) => {
|
||||
const chainHost = hostMap.get(hostId);
|
||||
// Jump hops are dialed over SSH; skip local/serial/telnet/plugin entries
|
||||
// that can never serve as a ProxyJump hop (e.g. saved local shells).
|
||||
if (!chainHost || !canServeAsSshJumpHost(chainHost)) return undefined;
|
||||
if (!chainHost) return undefined;
|
||||
return resolveEffectiveTerminalHost({
|
||||
host: chainHost,
|
||||
groupConfigs,
|
||||
|
||||
@@ -11,18 +11,3 @@ export function resolveEffectiveTerminalProtocol(host: TerminalTransportHost): H
|
||||
if (host.hostname === 'localhost') return 'local';
|
||||
return host.protocol ?? 'ssh';
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a vault host can serve as an SSH jump (ProxyJump) hop.
|
||||
*
|
||||
* Chain hops are dialed over SSH, so ssh-family transports (including mosh/et
|
||||
* hosts, which bootstrap over SSH) qualify, while local shells, serial links,
|
||||
* telnet and plugin transports must never be materialized as jump hosts.
|
||||
* Hosts without an explicit protocol are legacy SSH entries.
|
||||
*/
|
||||
export function canServeAsSshJumpHost(
|
||||
host: Pick<Host, 'protocol'>,
|
||||
): boolean {
|
||||
const protocol = host.protocol;
|
||||
return protocol === undefined || protocol === 'ssh' || protocol === 'mosh' || protocol === 'et';
|
||||
}
|
||||
@@ -41,28 +41,6 @@ test('buildVaultHostMergeKey treats group as part of session identity', () => {
|
||||
);
|
||||
});
|
||||
|
||||
test('buildVaultHostMergeKey normalizes the local-shell default port', () => {
|
||||
// Vault-editor local hosts omit `port`; CSV export/import round-trips store
|
||||
// the effective default (22). Both forms must dedupe as the same entry.
|
||||
const localBase = {
|
||||
hostname: 'localhost',
|
||||
username: '',
|
||||
protocol: 'local' as const,
|
||||
group: 'lan',
|
||||
os: 'windows' as const,
|
||||
localShellName: 'PowerShell',
|
||||
};
|
||||
assert.equal(
|
||||
buildVaultHostMergeKey({ ...localBase }),
|
||||
buildVaultHostMergeKey({ ...localBase, port: 22 }),
|
||||
);
|
||||
// An explicit non-default port is still a distinct identity.
|
||||
assert.notEqual(
|
||||
buildVaultHostMergeKey({ ...localBase, port: 2222 }),
|
||||
buildVaultHostMergeKey({ ...localBase, port: 22 }),
|
||||
);
|
||||
});
|
||||
|
||||
test('buildVaultHostFromDraft maps minimal unstructured fields to a vault host', () => {
|
||||
const built = buildVaultHostFromDraft({
|
||||
hostname: '192.168.1.10',
|
||||
|
||||
@@ -185,36 +185,19 @@ const parseKeyPath = (draft: VaultHostDraft): string | undefined => {
|
||||
export const buildVaultHostEndpointKey = (
|
||||
host: Pick<Host, 'hostname' | 'port' | 'username' | 'protocol'>,
|
||||
): string =>
|
||||
// Local-shell hosts created by the vault editor omit `port`, but CSV
|
||||
// export/import round-trips store the effective default (22). Normalize so
|
||||
// a local entry keeps a stable dedupe identity in both forms.
|
||||
`${(host.protocol ?? 'ssh').toLowerCase()}|${host.hostname.toLowerCase()}|${host.protocol === 'local' ? (host.port ?? DEFAULT_SSH_PORT) : host.port}|${(host.username ?? '').toLowerCase()}`;
|
||||
`${(host.protocol ?? 'ssh').toLowerCase()}|${host.hostname.toLowerCase()}|${host.port}|${(host.username ?? '').toLowerCase()}`;
|
||||
|
||||
/**
|
||||
* Session identity for vault import/create dedupe.
|
||||
* Same endpoint in a different group is a distinct connection (e.g. direct vs proxy copy).
|
||||
* Local-shell entries all share an endpoint key (local|localhost|22|<group>), so
|
||||
* their shell identity (shell, args, name, start dir, OS) is part of the key:
|
||||
* CMD and PowerShell entries in the same group must not collapse or skip each
|
||||
* other during any import dedupe stage.
|
||||
*/
|
||||
export const buildVaultHostMergeKey = (
|
||||
host: Pick<Host, 'hostname' | 'port' | 'username' | 'protocol' | 'group'>
|
||||
& Partial<Pick<Host, 'os' | 'localShell' | 'localShellArgs' | 'localShellName' | 'localStartDir'>>,
|
||||
host: Pick<Host, 'hostname' | 'port' | 'username' | 'protocol' | 'group'>,
|
||||
): string => {
|
||||
// Keep normalized group spelling (do not case-fold). Vault group paths are
|
||||
// compared exactly elsewhere, so Prod vs prod are distinct sessions.
|
||||
const group = normalizeGroupPath(host.group) ?? '';
|
||||
const endpointGroupKey = `${buildVaultHostEndpointKey(host)}|${group}`;
|
||||
if (host.protocol !== 'local') return endpointGroupKey;
|
||||
const shellIdentity = [
|
||||
host.os ?? '',
|
||||
host.localShell ?? '',
|
||||
host.localShellName ?? '',
|
||||
host.localShellArgs ? JSON.stringify(host.localShellArgs) : '',
|
||||
host.localStartDir ?? '',
|
||||
].join('|');
|
||||
return `${endpointGroupKey}|${shellIdentity}`;
|
||||
return `${buildVaultHostEndpointKey(host)}|${group}`;
|
||||
};
|
||||
|
||||
export function buildVaultHostFromDraft(
|
||||
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
filterVaultImportKeyPassphrasesAgainstExisting,
|
||||
resolveVaultImportKeyPassphraseConflicts,
|
||||
} from "./vaultImport.ts";
|
||||
import { encodeCsvPassphrase, encodeCsvLocalShell } from "./vaultImport/csvCredentialFields.ts";
|
||||
import { encodeCsvPassphrase } from "./vaultImport/csvCredentialFields.ts";
|
||||
import type { Host } from "./models.ts";
|
||||
|
||||
const mobaXtermSshSession = (
|
||||
@@ -527,73 +527,6 @@ test("applyVaultImportDestination does not override retained key auth with ident
|
||||
assert.equal(targeted.hosts[0]?.identityId, undefined);
|
||||
});
|
||||
|
||||
test("CSV import keeps default-shell local hosts distinct across OS values", () => {
|
||||
// The merge key for local hosts must include the host OS: two default-shell
|
||||
// entries (no shell/args/name/startDir) in the same group differ only by os,
|
||||
// and dropping the second would silently lose that OS's default shell.
|
||||
const csvQuote = (value: string): string => `"${value.replaceAll('"', '""')}"`;
|
||||
const result = importVaultHostsFromText("csv", [
|
||||
"Label,Hostname,Protocol,Group,LocalShell",
|
||||
`Win,localhost,local,Tools,${csvQuote(encodeCsvLocalShell({ os: "windows" }))}`,
|
||||
`Nix,localhost,local,Tools,${csvQuote(encodeCsvLocalShell({ os: "linux" }))}`,
|
||||
].join("\n"));
|
||||
|
||||
assert.deepEqual(result.issues, []);
|
||||
assert.equal(result.stats.duplicates, 0);
|
||||
assert.equal(result.hosts.length, 2);
|
||||
assert.deepEqual(
|
||||
result.hosts.map((host) => host.os),
|
||||
["windows", "linux"],
|
||||
);
|
||||
});
|
||||
|
||||
test("import destination keeps local shells distinct through the full pipeline", () => {
|
||||
// applyVaultImportDestination re-dedupe and applyVaultHostImport must not
|
||||
// collapse CMD/PowerShell entries back onto the endpoint-only merge key.
|
||||
const csvQuote = (value: string): string => `"${value.replaceAll('"', '""')}"`;
|
||||
const result = importVaultHostsFromText("csv", [
|
||||
"Label,Hostname,Protocol,Group,LocalShell",
|
||||
`CMD,localhost,local,Tools,${csvQuote(encodeCsvLocalShell({ os: "windows", shellName: "CMD" }))}`,
|
||||
`PS,localhost,local,Tools,${csvQuote(encodeCsvLocalShell({ os: "windows", shellName: "PowerShell" }))}`,
|
||||
].join("\n"));
|
||||
assert.equal(result.hosts.length, 2);
|
||||
|
||||
const targeted = applyVaultImportDestination(result, {
|
||||
mode: "group",
|
||||
group: "Imported",
|
||||
});
|
||||
assert.equal(targeted.hosts.length, 2);
|
||||
assert.deepEqual(
|
||||
targeted.hosts.map((host) => host.localShellName),
|
||||
["CMD", "PowerShell"],
|
||||
);
|
||||
|
||||
const applied = applyVaultHostImport([], [], targeted);
|
||||
assert.equal(applied.hosts.length, 2);
|
||||
assert.equal(applied.skippedExistingCount, 0);
|
||||
|
||||
// Re-importing the same backup into the same destination must skip both
|
||||
// existing shell entries instead of importing them again.
|
||||
const reapplied = applyVaultHostImport(applied.hosts, [], targeted);
|
||||
assert.equal(reapplied.hosts.length, 2);
|
||||
assert.equal(reapplied.skippedExistingCount, 2);
|
||||
});
|
||||
|
||||
test("applyVaultHostImport still dedupes identical local shells but keeps different shells", () => {
|
||||
const csvQuote = (value: string): string => `"${value.replaceAll('"', '""')}"`;
|
||||
const result = importVaultHostsFromText("csv", [
|
||||
"Label,Hostname,Protocol,Group,LocalShell",
|
||||
`CMD,localhost,local,Tools,${csvQuote(encodeCsvLocalShell({ os: "windows", shellName: "CMD" }))}`,
|
||||
`PS,localhost,local,Tools,${csvQuote(encodeCsvLocalShell({ os: "windows", shellName: "PowerShell" }))}`,
|
||||
].join("\n"));
|
||||
|
||||
const existingCmd = result.hosts[0]!;
|
||||
const applied = applyVaultHostImport([existingCmd], [], result);
|
||||
assert.equal(applied.hosts.length, 2);
|
||||
assert.equal(applied.skippedExistingCount, 1);
|
||||
assert.equal(applied.addedHosts[0]?.localShellName, "PowerShell");
|
||||
});
|
||||
|
||||
test("CSV import keeps working when KeyPath and Passphrase columns are absent", () => {
|
||||
const result = importVaultHostsFromText(
|
||||
"csv",
|
||||
|
||||
+2
-26
@@ -15,7 +15,7 @@ export {
|
||||
} from "./vaultHostCreate";
|
||||
import { parseQuickConnectInput } from "./quickConnect";
|
||||
import { findExactHeaderIndex, findHeaderIndex, parseCsv } from "./vaultImport/csvUtils";
|
||||
import { decodeCsvKeyPath, decodeCsvLocalShell, decodeCsvPassphrase } from "./vaultImport/csvCredentialFields";
|
||||
import { decodeCsvKeyPath, decodeCsvPassphrase } from "./vaultImport/csvCredentialFields";
|
||||
import { parseCsvProxy } from "./vaultImport/csvProxy";
|
||||
import { attachMobaXtermPasswords } from "./vaultImport/mobaXtermPasswords";
|
||||
import { decodeFinalShellPassword } from "./finalShellPassword";
|
||||
@@ -326,9 +326,7 @@ const createHost = (input: {
|
||||
|
||||
const dedupeHosts = (
|
||||
hosts: Host[],
|
||||
options?: {
|
||||
isCollapsible?: (host: Host) => boolean;
|
||||
},
|
||||
options?: { isCollapsible?: (host: Host) => boolean },
|
||||
): { hosts: Host[]; duplicates: number } => {
|
||||
const seen = new Map<string, Host>();
|
||||
const retained: Host[] = [];
|
||||
@@ -448,7 +446,6 @@ const importFromCsv = (text: string): VaultImportResult => {
|
||||
const keyPathIdx = findExactHeaderIndex(header, ["keypath", "key path", "identityfile", "identity file"]);
|
||||
const explicitPassphraseIdx = findExactHeaderIndex(header, ["passphrase", "keypassphrase", "key passphrase"]);
|
||||
const proxyIdx = findExactHeaderIndex(header, ["proxy", "proxyserver", "proxy server"]);
|
||||
const localShellIdx = findExactHeaderIndex(header, ["localshell"]);
|
||||
const passphraseIdx = keyPathIdx >= 0 ? explicitPassphraseIdx : -1;
|
||||
const exactPasswordIdx = findExactHeaderIndex(header, ["password", "pass", "passwd"]);
|
||||
const fuzzyNamedPasswordIdx = findHeaderIndex(header, ["password", "passwd"]);
|
||||
@@ -540,15 +537,6 @@ const importFromCsv = (text: string): VaultImportResult => {
|
||||
});
|
||||
}
|
||||
|
||||
const localShellRaw = (localShellIdx >= 0 ? row[localShellIdx] : undefined)?.trim();
|
||||
const localShellSpec = localShellRaw ? decodeCsvLocalShell(localShellRaw) : undefined;
|
||||
if (localShellRaw && !localShellSpec) {
|
||||
issues.push({
|
||||
level: "warning",
|
||||
message: `CSV row ${i + 2}: LocalShell was ignored because it is not a valid encoded local-shell value.`,
|
||||
});
|
||||
}
|
||||
|
||||
if (decodedPassphrase && isEncryptedCredentialPlaceholder(decodedPassphrase)) {
|
||||
issues.push({
|
||||
level: "warning",
|
||||
@@ -576,14 +564,6 @@ const importFromCsv = (text: string): VaultImportResult => {
|
||||
notes,
|
||||
});
|
||||
if (proxyConfig) host.proxyConfig = proxyConfig;
|
||||
if (localShellSpec && protocol === "local") {
|
||||
if (localShellSpec.shell) host.localShell = localShellSpec.shell;
|
||||
if (localShellSpec.shellArgs) host.localShellArgs = localShellSpec.shellArgs;
|
||||
if (localShellSpec.shellName) host.localShellName = localShellSpec.shellName;
|
||||
if (localShellSpec.shellIcon) host.localShellIcon = localShellSpec.shellIcon;
|
||||
if (localShellSpec.startDir) host.localStartDir = localShellSpec.startDir;
|
||||
if (localShellSpec.os) host.os = localShellSpec.os;
|
||||
}
|
||||
parsedHosts.push(host);
|
||||
if (keyPath && passphrase) {
|
||||
const keyPathKey = normalizeKeyPathKey(keyPath);
|
||||
@@ -606,10 +586,6 @@ const importFromCsv = (text: string): VaultImportResult => {
|
||||
}
|
||||
}
|
||||
|
||||
// Local-shell entries share the endpoint key (local|localhost|22|<group>), so
|
||||
// the shell identity is part of buildVaultHostMergeKey; otherwise importing a
|
||||
// backup with CMD and PowerShell in the same group silently keeps only the
|
||||
// first shell and flags the rest as duplicates.
|
||||
const { hosts, duplicates } = dedupeHosts(parsedHosts);
|
||||
const keyPassphrases = hosts.flatMap((host) => {
|
||||
const selectedKeyPath = host.identityFilePaths?.find((path) => path.trim())?.trim();
|
||||
|
||||
@@ -2,7 +2,6 @@ const FORMULA_PREFIX = /^[=+\-@\t\r]/u;
|
||||
const KEY_PATH_MARKER = "__netcatty_csv_keypath_v1__:";
|
||||
const PASSPHRASE_MARKER = "__netcatty_csv_passphrase_v1__:";
|
||||
const PROXY_MARKER = "__netcatty_csv_proxy_v1__:";
|
||||
const LOCAL_SHELL_MARKER = "__netcatty_csv_localshell_v1__:";
|
||||
|
||||
const encodeMarkedField = (value: string, marker: string): string => (
|
||||
FORMULA_PREFIX.test(value) || value.startsWith(marker)
|
||||
@@ -42,48 +41,3 @@ export const encodeCsvProxy = (value: string): string => (
|
||||
export const decodeCsvProxy = (value: string): string => (
|
||||
decodeMarkedField(value, PROXY_MARKER)
|
||||
);
|
||||
|
||||
export interface CsvLocalShellSpec {
|
||||
shell?: string;
|
||||
shellArgs?: string[];
|
||||
shellName?: string;
|
||||
shellIcon?: string;
|
||||
startDir?: string;
|
||||
/** Host OS of the local-shell entry; decides the default-shell fallback on connect. */
|
||||
os?: "linux" | "windows" | "macos";
|
||||
}
|
||||
|
||||
export const encodeCsvLocalShell = (spec: CsvLocalShellSpec): string => (
|
||||
encodeMarkedField(JSON.stringify(spec), LOCAL_SHELL_MARKER)
|
||||
);
|
||||
|
||||
const isNonEmptyString = (value: unknown): value is string => (
|
||||
typeof value === "string" && value.trim().length > 0
|
||||
);
|
||||
|
||||
const isNonEmptyStringArray = (value: unknown): value is string[] => (
|
||||
Array.isArray(value) && value.length > 0 && value.every(isNonEmptyString)
|
||||
);
|
||||
|
||||
export const decodeCsvLocalShell = (value: string): CsvLocalShellSpec | null => {
|
||||
const raw = decodeMarkedField(value, LOCAL_SHELL_MARKER).trim();
|
||||
if (!raw) return null;
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(raw);
|
||||
if (typeof parsed !== "object" || parsed === null) return null;
|
||||
const record = parsed as Record<string, unknown>;
|
||||
const spec: CsvLocalShellSpec = {
|
||||
...(isNonEmptyString(record.shell) ? { shell: record.shell } : {}),
|
||||
...(isNonEmptyStringArray(record.shellArgs) ? { shellArgs: record.shellArgs } : {}),
|
||||
...(isNonEmptyString(record.shellName) ? { shellName: record.shellName } : {}),
|
||||
...(isNonEmptyString(record.shellIcon) ? { shellIcon: record.shellIcon } : {}),
|
||||
...(isNonEmptyString(record.startDir) ? { startDir: record.startDir } : {}),
|
||||
...(record.os === "linux" || record.os === "windows" || record.os === "macos"
|
||||
? { os: record.os }
|
||||
: {}),
|
||||
};
|
||||
return Object.keys(spec).length > 0 ? spec : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
@@ -55,32 +55,6 @@ test("CSV round-trips local key authentication and its saved passphrase", () =>
|
||||
}]);
|
||||
});
|
||||
|
||||
test("CSV import keeps distinct local-shell entries in the same group", () => {
|
||||
const localShellHost = (id: string, label: string, shell: string, shellName: string): Host => ({
|
||||
...hostDefaults,
|
||||
id,
|
||||
label,
|
||||
hostname: "localhost",
|
||||
port: 22,
|
||||
protocol: "local",
|
||||
group: "Shells",
|
||||
localShell: shell,
|
||||
localShellName: shellName,
|
||||
});
|
||||
|
||||
const cmd = localShellHost("local-cmd", "Command Prompt", "cmd.exe", "Command Prompt");
|
||||
const powershell = localShellHost("local-ps", "PowerShell", "powershell.exe", "Windows PowerShell");
|
||||
const { csv } = exportHostsToCsvWithStats([cmd, powershell]);
|
||||
|
||||
const imported = importVaultHostsFromText("csv", csv);
|
||||
assert.equal(imported.stats.duplicates, 0);
|
||||
assert.equal(imported.hosts.length, 2);
|
||||
assert.deepEqual(
|
||||
imported.hosts.map((h) => h.localShell).sort(),
|
||||
["cmd.exe", "powershell.exe"],
|
||||
);
|
||||
});
|
||||
|
||||
test("CSV round-trips a referenced Keychain file path and saved passphrase", () => {
|
||||
const host: Host = {
|
||||
...hostDefaults,
|
||||
@@ -451,67 +425,3 @@ test("CSV reports unreadable proxy passwords for exported hosts only", () => {
|
||||
assert.deepEqual(imported.hosts.map((host) => host.proxyConfig?.password), [undefined, undefined, "readable"]);
|
||||
assert.equal(exportHostsToCsvWithStats([]).unreadableProxyCredentialCount, 0);
|
||||
});
|
||||
|
||||
test("CSV round-trips local-shell vault host settings", () => {
|
||||
const localHost: Host = {
|
||||
...hostDefaults,
|
||||
id: "local-cmd",
|
||||
label: "CMD host",
|
||||
hostname: "localhost",
|
||||
username: "",
|
||||
protocol: "local",
|
||||
os: "windows",
|
||||
tags: ["local"],
|
||||
localShell: "cmd",
|
||||
localShellArgs: ["/k", "echo hi"],
|
||||
localShellName: "CMD",
|
||||
localShellIcon: "cmd",
|
||||
localStartDir: "C:\\Users\\dev",
|
||||
};
|
||||
const defaultLocalHost: Host = {
|
||||
...hostDefaults,
|
||||
id: "local-default",
|
||||
label: "Local shell",
|
||||
hostname: "localhost",
|
||||
username: "",
|
||||
protocol: "local",
|
||||
os: "windows",
|
||||
tags: ["local"],
|
||||
group: "Backups",
|
||||
};
|
||||
|
||||
const { csv, exportedCount, skippedCount } = exportHostsToCsvWithStats([localHost, defaultLocalHost]);
|
||||
assert.equal(exportedCount, 2);
|
||||
assert.equal(skippedCount, 0);
|
||||
|
||||
const imported = importVaultHostsFromText("csv", csv);
|
||||
assert.equal(imported.hosts.length, 2);
|
||||
assert.equal(imported.issues.length, 0);
|
||||
|
||||
const roundTripped = imported.hosts[0];
|
||||
assert.equal(roundTripped.protocol, "local");
|
||||
assert.equal(roundTripped.os, "windows");
|
||||
assert.equal(roundTripped.localShell, "cmd");
|
||||
assert.deepEqual(roundTripped.localShellArgs, ["/k", "echo hi"]);
|
||||
assert.equal(roundTripped.localShellName, "CMD");
|
||||
assert.equal(roundTripped.localShellIcon, "cmd");
|
||||
assert.equal(roundTripped.localStartDir, "C:\\Users\\dev");
|
||||
|
||||
const roundTrippedDefault = imported.hosts[1];
|
||||
assert.equal(roundTrippedDefault.protocol, "local");
|
||||
assert.equal(roundTrippedDefault.os, "windows");
|
||||
assert.equal(roundTrippedDefault.localShell, undefined);
|
||||
assert.equal(roundTrippedDefault.localStartDir, undefined);
|
||||
});
|
||||
|
||||
test("CSV import warns and ignores a malformed LocalShell value", () => {
|
||||
const csv = [
|
||||
"Groups,Label,Tags,Notes,Hostname/IP,Protocol,Port,Username,Password,KeyPath,Passphrase,Proxy,LocalShell",
|
||||
',"Bad local shell",,,localhost,local,22,,,,,,"not json"',
|
||||
].join("\r\n");
|
||||
|
||||
const imported = importVaultHostsFromText("csv", csv);
|
||||
assert.equal(imported.hosts.length, 1);
|
||||
assert.equal(imported.hosts[0]?.localShell, undefined);
|
||||
assert.ok(imported.issues.some((issue) => issue.message.includes("row 2") && issue.message.includes("LocalShell")));
|
||||
});
|
||||
@@ -2,7 +2,7 @@ import type { GroupConfig, Host, Identity, ProxyProfile } from '../models';
|
||||
import { applyGroupDefaults, resolveGroupDefaults } from '../groupConfig';
|
||||
import { isPluginHostProtocol } from '../pluginConnection';
|
||||
import { hasUnreadableProxyCredential, materializeHostProxyProfile, resolveProxyConfigAuth } from '../proxyProfiles';
|
||||
import { CsvLocalShellSpec, encodeCsvKeyPath, encodeCsvLocalShell, encodeCsvPassphrase, encodeCsvProxy } from './csvCredentialFields';
|
||||
import { encodeCsvKeyPath, encodeCsvPassphrase, encodeCsvProxy } from './csvCredentialFields';
|
||||
import { formatCsvProxy } from './csvProxy';
|
||||
|
||||
const UTF8_BOM = "\uFEFF";
|
||||
@@ -36,7 +36,7 @@ export const getVaultCsvTemplate = (
|
||||
opts: VaultCsvTemplateOptions = {},
|
||||
): string => {
|
||||
const includeExampleRows = opts.includeExampleRows !== false;
|
||||
const header = ["Groups", "Label", "Tags", "Notes", "Hostname/IP", "Protocol", "Port", "Username", "Password", "KeyPath", "Passphrase", "Proxy", "LocalShell"];
|
||||
const header = ["Groups", "Label", "Tags", "Notes", "Hostname/IP", "Protocol", "Port", "Username", "Password", "KeyPath", "Passphrase", "Proxy"];
|
||||
const rows: string[][] = [header];
|
||||
if (includeExampleRows) {
|
||||
rows.push(["Project/Dev", "Web Server (dev)", "dev,web", "Dev web tier", "192.168.1.10", "ssh", "22", "root", "", "~/.ssh/id_ed25519", "", "socks5://127.0.0.1:1080"]);
|
||||
@@ -53,32 +53,8 @@ export const getVaultCsvTemplate = (
|
||||
return rows.map((r) => r.map((c) => escapeCsv(c)).join(",")).join("\r\n") + "\r\n";
|
||||
};
|
||||
|
||||
// The legacy CSV format has no per-host shell columns, so local-shell vault
|
||||
// hosts are serialized as one encoded "LocalShell" field; hosts with a
|
||||
// non-default shell are otherwise lost on reimport (backup data silently
|
||||
// changed). Default-shell Windows/macOS local hosts still export their OS:
|
||||
// reimport defaults to Linux, and the OS is part of the reimport dedupe key,
|
||||
// so a macOS default-shell entry without an OS field would be reimported as
|
||||
// (and collapse with) a Linux host. Linux default-shell hosts match the
|
||||
// reimport default and need no field.
|
||||
const getLocalShellSpec = (host: Host): CsvLocalShellSpec | null => (
|
||||
host.protocol === "local"
|
||||
&& (host.localShell || host.localShellName || host.localShellIcon
|
||||
|| host.localStartDir || host.localShellArgs?.length
|
||||
|| host.os === "windows" || host.os === "macos")
|
||||
? {
|
||||
shell: host.localShell || undefined,
|
||||
shellArgs: host.localShellArgs?.length ? host.localShellArgs : undefined,
|
||||
shellName: host.localShellName || undefined,
|
||||
shellIcon: host.localShellIcon || undefined,
|
||||
startDir: host.localStartDir || undefined,
|
||||
os: host.os === "windows" || host.os === "macos" ? host.os : undefined,
|
||||
}
|
||||
: null
|
||||
);
|
||||
|
||||
const exportHostsToCsv = (hosts: Host[], options: VaultCsvExportOptions) => {
|
||||
const header = ["Groups", "Label", "Tags", "Notes", "Hostname/IP", "Protocol", "Port", "Username", "Password", "KeyPath", "Passphrase", "Proxy", "LocalShell"];
|
||||
const header = ["Groups", "Label", "Tags", "Notes", "Hostname/IP", "Protocol", "Port", "Username", "Password", "KeyPath", "Passphrase", "Proxy"];
|
||||
const rows: string[][] = [header];
|
||||
let unreadableProxyCredentialCount = 0;
|
||||
|
||||
@@ -142,7 +118,6 @@ const exportHostsToCsv = (hosts: Host[], options: VaultCsvExportOptions) => {
|
||||
const proxyValue = proxyConfig
|
||||
? formatCsvProxy(resolveProxyConfigAuth(proxyConfig, options.identities))
|
||||
: "";
|
||||
const localShellSpec = getLocalShellSpec(host);
|
||||
|
||||
rows.push([
|
||||
host.group ?? "",
|
||||
@@ -157,7 +132,6 @@ const exportHostsToCsv = (hosts: Host[], options: VaultCsvExportOptions) => {
|
||||
encodeCsvKeyPath(keyPath),
|
||||
encodeCsvPassphrase(passphrase),
|
||||
encodeCsvProxy(proxyValue),
|
||||
localShellSpec ? encodeCsvLocalShell(localShellSpec) : "",
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
@@ -6,17 +6,7 @@ const { buildBashHistoryCleanup, bashHistoryScratchNames } = require("./ptyExecH
|
||||
// short-lived sh in the interactive PTY, rather than the SSH login shell.
|
||||
function buildLiveShellProbe(marker) {
|
||||
const script = 'if test -r "/proc/$PPID/comm"; then IFS= read -r name < "/proc/$PPID/comm"; else name=$(ps -p "$PPID" -o comm= 2>/dev/null); fi; '
|
||||
+ `printf "\\n${marker}_P:%s\\n" "$name"`;
|
||||
// Keep the initial probe short before the active shell is known. Quoted
|
||||
// multiline cleanup input can leave a configured zsh editor waiting for
|
||||
// continuation input, so defer that Bash-only work until after the reply.
|
||||
return ` true ${marker}; printf '\\n%s\\n' '${marker}_I'\n : '${marker}'; command sh -c '${script}' 2>/dev/null; printf '%s' '${marker}_Q'\n`;
|
||||
}
|
||||
|
||||
// This is sent after the short probe, except for zsh/fish replies. Keep the
|
||||
// existing guarded cleanup and bounded physical lines for Bash's history and
|
||||
// canonical-input behavior, including renamed Bash executables.
|
||||
function buildLiveShellProbeCleanup(marker) {
|
||||
+ `printf "${marker}_P:%s\\n" "$name"`;
|
||||
// command eval bypasses an eval customization; plain eval is the fallback
|
||||
// when command itself is shadowed. Never invoke a shadowed builtin after the
|
||||
// command path already succeeded. Both eval bodies remain Bash-guarded.
|
||||
@@ -24,7 +14,10 @@ function buildLiveShellProbeCleanup(marker) {
|
||||
const { dispatcher } = bashHistoryScratchNames(marker);
|
||||
const clear = `[ -z "\${${dispatcher}-}" ]||$${dispatcher} unset ${dispatcher}`;
|
||||
const fallback = `[ "\${${dispatcher}-}" = command ]||{ ${cleanup}; };${clear}`;
|
||||
return ` : '${marker}'; \\command eval '${cleanup}' 2>/dev/null || true; \\\n: '${marker}'; \\eval '${fallback}' 2>/dev/null || true; \\\n: '${marker}'; \\command eval '${clear}' 2>/dev/null || true\n`;
|
||||
// Start display suppression in the PTY before any continuation is read,
|
||||
// independently of PS2 and echo mode. Keep every later physical line short.
|
||||
return ` true ${marker}; printf '\\n%s\\n' '${marker}_I'\n : '${marker}'; command sh -c '${script}' 2>/dev/null; \\\n: '${marker}'; \\command eval '${cleanup}' 2>/dev/null || true; \\\n: '${marker}'; \\eval '${fallback}' 2>/dev/null || true; \\\n: '${marker}'; \\command eval '${clear}' 2>/dev/null || true; printf '%s' '${marker}_Q'\n`;
|
||||
|
||||
}
|
||||
|
||||
// Interactive bash prints PS2 (`> ` by default) on backslash continuations.
|
||||
@@ -42,12 +35,11 @@ function parseLiveShellProbe(output, marker) {
|
||||
if (!normalized.startsWith(`${marker}_P:`)) continue;
|
||||
const name = normalized.slice(marker.length + 3).trim().split("/").pop().replace(/^-/, "");
|
||||
return {
|
||||
shellName: name,
|
||||
kind: name === "fish" ? "fish"
|
||||
: /^(?:ba|da|z|k|a)?sh$/.test(name) ? "posix" : null,
|
||||
};
|
||||
}
|
||||
return { kind: null, shellName: "" };
|
||||
return { kind: null };
|
||||
}
|
||||
|
||||
module.exports = { buildLiveShellProbe, buildLiveShellProbeCleanup, parseLiveShellProbe, normalizeLiveShellProbeLine };
|
||||
module.exports = { buildLiveShellProbe, parseLiveShellProbe, normalizeLiveShellProbeLine };
|
||||
@@ -1,7 +1,7 @@
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const { EventEmitter } = require('node:events');
|
||||
const { buildLiveShellProbe, buildLiveShellProbeCleanup, parseLiveShellProbe } = require('./liveShellProbe.cjs');
|
||||
const { buildLiveShellProbe, parseLiveShellProbe } = require('./liveShellProbe.cjs');
|
||||
const { startPtyJob } = require('./ptyExec.cjs');
|
||||
|
||||
test('live shell response excludes echoed commands, stale markers and partial lines', () => {
|
||||
@@ -9,11 +9,11 @@ test('live shell response excludes echoed commands, stale markers and partial li
|
||||
assert.equal(parseLiveShellProbe(buildLiveShellProbe(marker), marker), null);
|
||||
assert.equal(parseLiveShellProbe(`${marker}_P:fi`, marker), null);
|
||||
assert.equal(parseLiveShellProbe('__NCMCP_old___P:fish\n', marker), null);
|
||||
assert.deepEqual(parseLiveShellProbe(`\r${marker}_P:/usr/bin/fish\r\n${marker}_Q`, marker), { kind: 'fish', shellName: 'fish' });
|
||||
assert.deepEqual(parseLiveShellProbe(`${marker}_P:-zsh\n${marker}_Q`, marker), { kind: 'posix', shellName: 'zsh' });
|
||||
assert.deepEqual(parseLiveShellProbe(`${marker}_P:\n${marker}_Q`, marker), { kind: null, shellName: '' });
|
||||
assert.deepEqual(parseLiveShellProbe(`> ${marker}_P:bash\n> ${marker}_Q`, marker), { kind: 'posix', shellName: 'bash' });
|
||||
assert.deepEqual(parseLiveShellProbe(`> ${marker}_P:fish\n> ${marker}_Q`, marker), { kind: 'fish', shellName: 'fish' });
|
||||
assert.deepEqual(parseLiveShellProbe(`\r${marker}_P:/usr/bin/fish\r\n${marker}_Q`, marker), { kind: 'fish' });
|
||||
assert.deepEqual(parseLiveShellProbe(`${marker}_P:-zsh\n${marker}_Q`, marker), { kind: 'posix' });
|
||||
assert.deepEqual(parseLiveShellProbe(`${marker}_P:\n${marker}_Q`, marker), { kind: null });
|
||||
assert.deepEqual(parseLiveShellProbe(`> ${marker}_P:bash\n> ${marker}_Q`, marker), { kind: 'posix' });
|
||||
assert.deepEqual(parseLiveShellProbe(`> ${marker}_P:fish\n> ${marker}_Q`, marker), { kind: 'fish' });
|
||||
});
|
||||
|
||||
test('probe waits for complete reply before choosing the first wrapper', async () => {
|
||||
@@ -260,7 +260,7 @@ for (const [customization, listHistory] of [
|
||||
const { buildWrappedCommand } = require('./ptyExecHelpers.cjs');
|
||||
const marker = '__NCMCP_HISTORY_PROBE__';
|
||||
const input = `HISTFILE=/dev/null; HISTCONTROL=; PS1=; PS2=\n${customization}\n${listHistory} -c\necho user_one\necho user_two\n`
|
||||
+ buildLiveShellProbe(marker) + buildLiveShellProbeCleanup(marker)
|
||||
+ buildLiveShellProbe(marker)
|
||||
+ (executeCommand ? buildWrappedCommand('echo command_ok', 'posix', marker, true) : '')
|
||||
+ '\nprintf \"\\n\"\n' + listHistory + '\nexit\n';
|
||||
const result = spawnSync('/bin/bash', ['--noprofile', '--norc', '-i'], {
|
||||
@@ -337,7 +337,7 @@ for (const invocationName of ['sh', 'renamed-bash']) {
|
||||
const marker = '__NCMCP_RENAMED_BASH__';
|
||||
const result = spawnSync(shell, ['--noprofile', '--norc', '-i'], {
|
||||
input: 'HISTFILE=/dev/null; HISTCONTROL=; PS1=; PS2=\nbuiltin history -c\necho preserve_user_history\n'
|
||||
+ buildLiveShellProbe(marker) + buildLiveShellProbeCleanup(marker) + '\nprintf "\\n"\ncommand builtin history\nexit\n',
|
||||
+ buildLiveShellProbe(marker) + '\nprintf "\\n"\ncommand builtin history\nexit\n',
|
||||
encoding: 'utf8', env: { ...process.env, TERM: 'dumb' }, timeout: 5000,
|
||||
});
|
||||
assert.equal(result.status, 0, result.stderr);
|
||||
@@ -389,7 +389,7 @@ for (const historyControl of ['', 'ignorespace']) {
|
||||
const { buildWrappedCommand } = require('./ptyExecHelpers.cjs');
|
||||
const marker = '__NCMCP_FALLBACK_GUARD__';
|
||||
const input = `HISTFILE=/dev/null; HISTCONTROL=${historyControl}; PS1=; PS2=\nbuiltin() { printf UNEXPECTED_FALLBACK; }\ncommand history -c\necho user_one\n`
|
||||
+ buildLiveShellProbe(marker) + buildLiveShellProbeCleanup(marker)
|
||||
+ buildLiveShellProbe(marker)
|
||||
+ buildWrappedCommand('echo command_ok', 'posix', marker, true)
|
||||
+ '\ncommand history\nexit\n';
|
||||
const result = spawnSync('/bin/bash', ['--noprofile', '--norc', '-i'], {
|
||||
@@ -406,7 +406,7 @@ for (const historyControl of ['', 'ignorespace']) {
|
||||
}
|
||||
|
||||
for (const stop of ['cancel', 'timeout']) {
|
||||
test(`paced Bash probe cleanup stops writing after ${stop}`, async () => {
|
||||
test(`paced probe stops writing after ${stop}`, async () => {
|
||||
const pty = new EventEmitter();
|
||||
const writes = [];
|
||||
pty.write = data => writes.push(data);
|
||||
@@ -414,9 +414,8 @@ for (const stop of ['cancel', 'timeout']) {
|
||||
shellKind: 'posix', probeLiveShell: true, timeoutMs: stop === 'timeout' ? 70 : 1000,
|
||||
enforceWallTimeout: stop === 'timeout',
|
||||
});
|
||||
pty.emit('data', `${job.marker}_P:bash\n${job.marker}_Q`);
|
||||
await new Promise(resolve => setTimeout(resolve, 45));
|
||||
assert.ok(writes.length >= 3, 'cleanup must have started a later chunk');
|
||||
assert.ok(writes.length >= 2, 'probe must have started a later chunk');
|
||||
if (stop === 'cancel') {
|
||||
job.cancel();
|
||||
pty.emit('close');
|
||||
@@ -428,19 +427,3 @@ for (const stop of ['cancel', 'timeout']) {
|
||||
assert.ok(writes.every(data => !data.includes('must_not_run')));
|
||||
});
|
||||
}
|
||||
|
||||
for (const shellName of ['zsh', '/bin/zsh', 'zsh-5.9', 'fish', 'bash', 'sh', 'renamed-bash']) {
|
||||
test(`live probe defers Bash history input until shell reply (${shellName})`, async () => {
|
||||
const pty = new EventEmitter();
|
||||
const writes = [];
|
||||
pty.write = (data) => writes.push(String(data));
|
||||
const job = startPtyJob(pty, 'printf success', { shellKind: 'posix', probeLiveShell: true, timeoutMs: 1000 });
|
||||
assert.doesNotMatch(writes.join(''), /history -d|printf success/);
|
||||
pty.emit('data', `${job.marker}_P:${shellName}\n${job.marker}_Q`);
|
||||
const deferred = writes.slice(1).join('');
|
||||
const needsCleanup = shellName !== 'fish' && !/zsh/.test(shellName);
|
||||
assert.equal(deferred.includes('\\command eval'), needsCleanup);
|
||||
pty.emit('data', `${job.marker}_S\nsuccess\n${job.marker}_E:0\n`);
|
||||
assert.equal((await job.resultPromise).exitCode, 0);
|
||||
});
|
||||
}
|
||||
@@ -28,7 +28,7 @@ const {
|
||||
} = require("./ptyExecHelpers.cjs");
|
||||
const { extractTrailingIdlePrompt } = require("./shellUtils.cjs");
|
||||
|
||||
const { buildLiveShellProbe, buildLiveShellProbeCleanup, parseLiveShellProbe } = require("./liveShellProbe.cjs");
|
||||
const { buildLiveShellProbe, parseLiveShellProbe } = require("./liveShellProbe.cjs");
|
||||
|
||||
const DEFAULT_FOREGROUND_PTY_CAPTURE_CHARS = 1024 * 1024;
|
||||
const END_MARKER_PROMPT_WAIT_MS = 30000;
|
||||
@@ -560,9 +560,7 @@ function startPtyJob(ptyStream, command, options) {
|
||||
probeOutput = "";
|
||||
if (probe.kind) resolvedShellKind = probe.kind;
|
||||
if (finished || cancelRequested) return;
|
||||
const isZsh = /^zsh(?:[-.][0-9].*)?$/i.test(probe.shellName);
|
||||
const skipProbeCleanup = probe.kind === "fish" || isZsh;
|
||||
writeWrappedCommand(skipProbeCleanup ? "" : buildLiveShellProbeCleanup(marker), isZsh);
|
||||
writeWrappedCommand();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -824,14 +822,9 @@ function startPtyJob(ptyStream, command, options) {
|
||||
writeNext();
|
||||
}
|
||||
|
||||
function writeWrappedCommand(probeCleanup = "", isZsh = false) {
|
||||
// The Bash-only cleanup is a no-op in zsh, but its long input still goes
|
||||
// through the configured editor. Keep the existing bounded wrapper and
|
||||
// omit only that unused fragment after a live zsh reply.
|
||||
const wrapped = buildWrappedCommand(command, resolvedShellKind, marker, probeLiveShell, {
|
||||
omitBashHistoryCleanup: isZsh,
|
||||
});
|
||||
writeInput(`${(skipPendingInputClear ? "" : buildPendingInputClearPrefix(resolvedShellKind))}${probeCleanup}${wrapped}`);
|
||||
function writeWrappedCommand() {
|
||||
const wrapped = buildWrappedCommand(command, resolvedShellKind, marker, probeLiveShell);
|
||||
writeInput(`${(skipPendingInputClear ? "" : buildPendingInputClearPrefix(resolvedShellKind))}${wrapped}`);
|
||||
}
|
||||
|
||||
// Prime the renderer's display suppression before the first byte is typed
|
||||
|
||||
@@ -243,7 +243,7 @@ function buildBashHistoryCleanup(marker, keepDispatcher = false) {
|
||||
].join(` \\\n: "${marker}"; `);
|
||||
}
|
||||
|
||||
function buildPosixWrapperBody(command, marker, startFormat, { omitBashHistoryCleanup = false } = {}) {
|
||||
function buildPosixWrapperBody(command, marker, startFormat) {
|
||||
const noPager = "PAGER=cat SYSTEMD_PAGER= GIT_PAGER=cat LESS= ";
|
||||
const commandLines = String(command || "").replace(/\r\n?/g, "\n").split("\n");
|
||||
let cmdAssign = commandLines.length > 1
|
||||
@@ -273,7 +273,7 @@ function buildPosixWrapperBody(command, marker, startFormat, { omitBashHistoryCl
|
||||
}
|
||||
cmdAssign = `${marker}_cmd=$(${writes.join(`; \\\n: '${marker}'; `)})`;
|
||||
}
|
||||
const historyCleanup = omitBashHistoryCleanup ? [] : [buildBashHistoryCleanup(marker)];
|
||||
const historyCleanup = buildBashHistoryCleanup(marker);
|
||||
// OpenWrt builds BusyBox with a 512-byte interactive line editor. Keep
|
||||
// every physical line below that limit, even for short user commands.
|
||||
// Emit a short standalone input marker before the shell reads any PS2
|
||||
@@ -282,12 +282,12 @@ function buildPosixWrapperBody(command, marker, startFormat, { omitBashHistoryCl
|
||||
` : '${marker}'; ${cmdAssign}`,
|
||||
`{ printf '${startFormat}' '${marker}_S'; trap ':' INT; ( ${noPager}eval "$${marker}_cmd" ); __NCMCP_rc=$?; trap - INT`,
|
||||
`printf '%s\\n' '${marker}_E:'"$__NCMCP_rc"`,
|
||||
...historyCleanup,
|
||||
historyCleanup,
|
||||
`(exit $__NCMCP_rc); }`,
|
||||
].join(`; \\\n: '${marker}'; `);
|
||||
}
|
||||
|
||||
function buildWrappedCommand(command, shellKind, marker, separateStartMarker = false, options = {}) {
|
||||
function buildWrappedCommand(command, shellKind, marker, separateStartMarker = false) {
|
||||
// A live probe leaves its completion marker unterminated to hide the next
|
||||
// prompt. POSIX input also hides arbitrary PS2 prompts until a fresh start
|
||||
// marker line; keep the fish behavior controlled by separateStartMarker.
|
||||
@@ -366,7 +366,7 @@ function buildWrappedCommand(command, shellKind, marker, separateStartMarker = f
|
||||
// can opt in by adding `HISTCONTROL=ignoreboth` to ~/.bashrc.
|
||||
// Without that config the prefix is harmless; it just doesn't
|
||||
// suppress history recording.
|
||||
return ` ${buildPosixWrapperBody(command, marker, "\\n%s\\n", options)}\n`;
|
||||
return ` ${buildPosixWrapperBody(command, marker, "\\n%s\\n")}\n`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,27 +8,24 @@ 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, options = {}) {
|
||||
async function publishLocalFileExclusive(source, target, assertNotCancelled = () => {}, preparedHandle) {
|
||||
assertNotCancelled();
|
||||
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;
|
||||
}
|
||||
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();
|
||||
@@ -52,31 +49,14 @@ 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.
|
||||
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;
|
||||
}
|
||||
await output.chmod(stat.mode & 0o7777);
|
||||
await output.utimes(stat.atime, stat.mtime);
|
||||
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, timestampsPreserved };
|
||||
publishedIdentity = { dev: ownedStat.dev, ino: ownedStat.ino, size: ownedStat.size };
|
||||
} catch (error) {
|
||||
failure = error;
|
||||
} finally {
|
||||
@@ -93,14 +73,4 @@ async function publishLocalFileExclusive(source, target, assertNotCancelled = ()
|
||||
return publishedIdentity;
|
||||
}
|
||||
|
||||
// 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 };
|
||||
module.exports = { publishLocalFileExclusive };
|
||||
@@ -727,15 +727,6 @@ 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 };
|
||||
}
|
||||
@@ -1624,17 +1615,6 @@ 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,
|
||||
@@ -2252,16 +2232,10 @@ 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";
|
||||
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 || {};
|
||||
if (!hasCommandablePty && !hasSshExec && !hasSerialPort) continue;
|
||||
|
||||
// Look up metadata scoped to this chat session
|
||||
const meta = getSessionMeta(sessionId, chatSessionId) || {};
|
||||
hosts.push({
|
||||
sessionId,
|
||||
hostname: meta.hostname || session.hostname || "",
|
||||
@@ -2271,7 +2245,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 : hasTransport,
|
||||
connected: meta.connected !== undefined ? meta.connected : !!(session.sshClient || session.conn || ptyStream || session.serialPort),
|
||||
hostId: meta.hostId || "",
|
||||
hostChain: meta.hostChain || [],
|
||||
activePortForwards: meta.activePortForwards || [],
|
||||
@@ -2283,10 +2257,7 @@ async function handleGetContext(params) {
|
||||
for (const sessionId of resolvedScopedIds) {
|
||||
if (addedHostIds.has(sessionId)) continue;
|
||||
const meta = getSessionMeta(sessionId, chatSessionId);
|
||||
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).
|
||||
if (!meta || meta.connected === false) continue;
|
||||
hosts.push(buildHostFromMetadata(sessionId, meta));
|
||||
addedHostIds.add(sessionId);
|
||||
}
|
||||
@@ -2307,7 +2278,6 @@ 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.",
|
||||
|
||||
@@ -1,162 +0,0 @@
|
||||
"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,54 +560,6 @@ 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) => {
|
||||
@@ -630,11 +582,16 @@ 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);
|
||||
|
||||
interceptPreparedChmod(t, async () => {
|
||||
const error = new Error("injected chmod failure");
|
||||
error.code = "EPERM";
|
||||
throw error;
|
||||
});
|
||||
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; });
|
||||
|
||||
await assert.rejects(
|
||||
() => sftpBridge.downloadSftpToLocal(null, {
|
||||
@@ -673,14 +630,18 @@ 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; });
|
||||
interceptPreparedChmod(t, async (chmod, mode) => {
|
||||
markChmodStarted();
|
||||
await new Promise((resolve) => { releaseChmod = resolve; });
|
||||
return chmod(mode);
|
||||
});
|
||||
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; });
|
||||
|
||||
const running = sftpBridge.downloadSftpToLocal(null, {
|
||||
sftpId: "scp-late-directory",
|
||||
@@ -724,14 +685,18 @@ 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; });
|
||||
interceptPreparedChmod(t, async (chmod, mode) => {
|
||||
markChmodStarted();
|
||||
await new Promise((resolve) => { releaseChmod = resolve; });
|
||||
return chmod(mode);
|
||||
});
|
||||
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; });
|
||||
|
||||
const running = sftpBridge.downloadSftpToLocal(null, {
|
||||
sftpId: "scp-same-mode-replacement",
|
||||
@@ -1021,17 +986,3 @@ 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; });
|
||||
}
|
||||
@@ -1040,7 +1040,7 @@ function stableLocalFileIdentity(statLike) {
|
||||
}
|
||||
|
||||
async function promoteLocalTransfer(stagedPath, targetPath, options = {}) {
|
||||
const { publishLocalFileExclusive, isMetadataUnsupportedError } = require("./localFilePublish.cjs");
|
||||
const { publishLocalFileExclusive } = 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,7 +1050,6 @@ 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;
|
||||
@@ -1061,28 +1060,7 @@ async function promoteLocalTransfer(stagedPath, targetPath, options = {}) {
|
||||
await fs.promises.rename(stagedPath, readyPath);
|
||||
} catch (error) {
|
||||
if (error?.code !== "EXDEV") throw error;
|
||||
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;
|
||||
}
|
||||
}
|
||||
await fs.promises.copyFile(stagedPath, readyPath, fs.constants.COPYFILE_EXCL);
|
||||
}
|
||||
// Stamp the private prepared file before applying possibly unreadable
|
||||
// destination permissions. Publication carries these times to the target.
|
||||
@@ -1107,17 +1085,7 @@ async function promoteLocalTransfer(stagedPath, targetPath, options = {}) {
|
||||
? validatedTarget.existingMode & 0o7777
|
||||
: Number.isInteger(options.existingMode) ? options.existingMode & 0o7777 : null;
|
||||
if (mode !== null && mode !== appliedMode) {
|
||||
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");
|
||||
}
|
||||
}
|
||||
await fs.promises.chmod(readyPath, mode);
|
||||
appliedMode = mode;
|
||||
continue;
|
||||
}
|
||||
@@ -1163,9 +1131,7 @@ async function promoteLocalTransfer(stagedPath, targetPath, options = {}) {
|
||||
assertNotCancelled();
|
||||
let publishedIdentity = null;
|
||||
try {
|
||||
publishedIdentity = await publishLocalFileExclusive(readyPath, targetPath, assertNotCancelled, preparedHandle, {
|
||||
preserveMode: appliedMode !== null, requireTimestamps: false,
|
||||
}) ?? null;
|
||||
publishedIdentity = await publishLocalFileExclusive(readyPath, targetPath, assertNotCancelled, preparedHandle) ?? null;
|
||||
} catch (error) {
|
||||
if (error?.code === "EEXIST") {
|
||||
throw new Error("Local download target changed during replacement", { cause: error });
|
||||
@@ -1178,13 +1144,12 @@ 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 && publishedIdentity?.timestampsPreserved !== false);
|
||||
options.onCommit?.(publishedIdentity, localMtimePrepared);
|
||||
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
|
||||
@@ -1214,7 +1179,7 @@ async function promoteLocalTransfer(stagedPath, targetPath, options = {}) {
|
||||
if (backedUp) failure.remoteBackupPath = backupPath;
|
||||
throw failure;
|
||||
}
|
||||
if (!skipReadyCleanup) await fs.promises.unlink(readyPath).catch(() => {});
|
||||
await fs.promises.unlink(readyPath).catch(() => {});
|
||||
throw error;
|
||||
} finally {
|
||||
await preparedHandle?.close().catch(() => {});
|
||||
@@ -3058,10 +3023,6 @@ function openSftpHandleForTransfer(sftp, filePath, flags, transfer, options = {}
|
||||
transfer.abort = previousAbort;
|
||||
}
|
||||
try { sftp.removeListener?.("error", onOpenChannelError); } catch { /* ignore */ }
|
||||
if (!isWriteOpen) {
|
||||
sftp.removeListener?.("end", onOpenChannelClosed);
|
||||
sftp.removeListener?.("close", onOpenChannelClosed);
|
||||
}
|
||||
fn(value);
|
||||
};
|
||||
|
||||
@@ -3118,10 +3079,6 @@ function openSftpHandleForTransfer(sftp, filePath, flags, transfer, options = {}
|
||||
}
|
||||
};
|
||||
|
||||
const onOpenChannelClosed = () => {
|
||||
onOpenChannelError(new Error("SFTP channel closed before the source file was opened"));
|
||||
};
|
||||
|
||||
if (transfer.cancelled) {
|
||||
completeSharedWriteDrain();
|
||||
releasePathGate();
|
||||
@@ -3131,16 +3088,6 @@ function openSftpHandleForTransfer(sftp, filePath, flags, transfer, options = {}
|
||||
|
||||
transfer.abort = abortDuringOpen;
|
||||
sftp.on?.("error", onOpenChannelError);
|
||||
if (!isWriteOpen) {
|
||||
sftp.on?.("end", onOpenChannelClosed);
|
||||
sftp.on?.("close", onOpenChannelClosed);
|
||||
// A failed isolated download may fall back after the browse channel's
|
||||
// end event already fired. ssh2 never answers a new OPEN on that channel.
|
||||
if (sftp.readable === false) {
|
||||
onOpenChannelClosed();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
sftp.open(filePath, resolveFlags(), (error, handle) => {
|
||||
|
||||
@@ -1,68 +0,0 @@
|
||||
"use strict";
|
||||
|
||||
const test = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const fs = require("node:fs/promises");
|
||||
const path = require("node:path");
|
||||
const { randomUUID } = require("node:crypto");
|
||||
const { EventEmitter } = require("node:events");
|
||||
const transferBridge = require("./transferBridge.cjs");
|
||||
const tempDirBridge = require("./tempDirBridge.cjs");
|
||||
|
||||
for (const scenario of ["already-ended", "end-during-open", "close-during-open"]) {
|
||||
test(`SFTP download settles when its read channel is ${scenario}`, async (t) => {
|
||||
const transferId = `closed-read-${randomUUID()}`;
|
||||
const targetPath = tempDirBridge.getTempFilePath("closed-read.bin");
|
||||
const stagePath = tempDirBridge.getTransferTempFilePath(transferId, path.basename(targetPath));
|
||||
let timer;
|
||||
let running;
|
||||
t.after(async () => {
|
||||
clearTimeout(timer);
|
||||
await transferBridge.cancelTransfer({}, { transferId });
|
||||
await running;
|
||||
await fs.rm(targetPath, { force: true });
|
||||
await fs.rm(stagePath, { force: true });
|
||||
});
|
||||
const sftp = new EventEmitter();
|
||||
sftp.readable = scenario !== "already-ended";
|
||||
sftp.readdir = (_path, callback) => callback(null, []);
|
||||
sftp.stat = (_path, callback) => callback(null, { size: 1 });
|
||||
sftp.mkdir = (_path, callback) => callback(null);
|
||||
sftp.unlink = (_path, callback) => callback(null);
|
||||
sftp.open = () => {
|
||||
// ssh2 cannot respond to a new request once its channel has ended.
|
||||
if (scenario === "already-ended") return;
|
||||
queueMicrotask(() => {
|
||||
sftp.readable = false;
|
||||
sftp.emit(scenario === "end-during-open" ? "end" : "close");
|
||||
});
|
||||
};
|
||||
sftp.read = () => assert.fail("a closed channel must not start body reads");
|
||||
const client = {
|
||||
sftp,
|
||||
__netcattySudoMode: true,
|
||||
stat: async () => ({ size: 1 }),
|
||||
};
|
||||
transferBridge.init({ sftpClients: new Map([["source", client]]) });
|
||||
running = transferBridge.startTransfer({ sender: { send() {} } }, {
|
||||
transferId,
|
||||
sourceType: "sftp",
|
||||
targetType: "local",
|
||||
sourceSftpId: "source",
|
||||
sourcePath: "/source.bin",
|
||||
targetPath,
|
||||
totalBytes: 1,
|
||||
resumable: true,
|
||||
});
|
||||
const result = await Promise.race([
|
||||
running,
|
||||
new Promise((_, reject) => {
|
||||
timer = setTimeout(() => reject(new Error("download hung after its channel closed")), 500);
|
||||
}),
|
||||
]);
|
||||
assert.match(result.error || "", /SFTP channel closed/i);
|
||||
assert.equal(sftp.listenerCount("end"), 0);
|
||||
assert.equal(sftp.listenerCount("close"), 0);
|
||||
assert.equal(sftp.listenerCount("error"), 0);
|
||||
});
|
||||
}
|
||||
@@ -1,189 +0,0 @@
|
||||
"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"]);
|
||||
});
|
||||
}
|
||||
@@ -48,7 +48,7 @@ const VAULT_CAPABILITIES = [
|
||||
domain: "vault",
|
||||
status: CAPABILITY_STATUS.IMPLEMENTED,
|
||||
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.",
|
||||
"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.",
|
||||
// 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],
|
||||
|
||||
@@ -83,7 +83,6 @@ 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: {
|
||||
@@ -421,7 +420,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":
|
||||
"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.",
|
||||
"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.",
|
||||
"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,7 +47,6 @@ function createVaultService(ctx = {}) {
|
||||
return invokeVaultAgent("host.open", {
|
||||
hostId: params.hostId,
|
||||
chatSessionId: params.chatSessionId,
|
||||
newTab: params.newTab,
|
||||
});
|
||||
},
|
||||
createHosts: async (params = {}) => {
|
||||
|
||||
@@ -36,11 +36,10 @@ 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", newTab: "true" });
|
||||
const result = await service.openHost({ hostId: "host-1", chatSessionId: "chat-1" });
|
||||
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,17 +585,12 @@
|
||||
"toolName": "host_open",
|
||||
"rpcMethod": "vault/hosts/open",
|
||||
"localExecution": false,
|
||||
"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.",
|
||||
"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.",
|
||||
"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": {
|
||||
|
||||
@@ -1,146 +0,0 @@
|
||||
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' });
|
||||
});
|
||||
@@ -162,8 +162,6 @@ export interface ChatMessage {
|
||||
providerContinuation?: ProviderContinuation;
|
||||
toolCalls?: ToolCall[];
|
||||
toolResults?: ToolResult[];
|
||||
/** User-skill pills selected for this turn (user messages); restored on undo. */
|
||||
selectedUserSkillSlugs?: string[];
|
||||
agentActivities?: AgentActivity[];
|
||||
usage?: AgentUsage;
|
||||
timestamp: number;
|
||||
|
||||
@@ -496,110 +496,6 @@ 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',
|
||||
|
||||
Loaded 100 of 106 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user