mirror of
https://github.com/binaricat/Netcatty.git
synced 2026-10-29 18:11:40 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b00e1458a7 | ||
|
|
21bd5dbff8 | ||
|
|
6a3977de09 | ||
|
|
2fe3868d65 | ||
|
|
c942de1cbb | ||
|
|
3e7d871a33 | ||
|
|
758966a79c | ||
|
|
f0458dd7cc | ||
|
|
52797e9edd | ||
|
|
cc001c9a4b | ||
|
|
b81b27f817 | ||
|
|
3a2bf6498d | ||
|
|
53a4bf29b0 | ||
|
|
0ed481c397 | ||
|
|
e85c1fea30 | ||
|
|
6c2bf43ee5 | ||
|
|
abdf254702 | ||
|
|
35b83d4655 | ||
|
|
b6b575b045 | ||
|
|
3d5cd6b8ec |
No files matched your search
@@ -7,6 +7,7 @@ import {
|
||||
handleKeyboardInteractiveSubmitImpl,
|
||||
handleTrayPanelConnectRequestImpl,
|
||||
} from './app/AppHandlers.ts';
|
||||
import { resolveShellSetting } from '../lib/useDiscoveredShells.ts';
|
||||
import type { Host } from '../types';
|
||||
|
||||
const baseHost: Host = {
|
||||
@@ -398,3 +399,69 @@ 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,6 +4,7 @@ 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';
|
||||
@@ -204,7 +205,10 @@ export function handleTrayPanelConnectImpl(getCtx: AppContextGetter, hostId: str
|
||||
hostId: host.id,
|
||||
hostLabel: host.label,
|
||||
hostname: host.hostname,
|
||||
username: resolvedAuth.username || 'root',
|
||||
// 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'),
|
||||
protocol,
|
||||
...getLogHostVisualSnapshot(effectiveHost),
|
||||
startTime: Date.now(),
|
||||
@@ -1104,11 +1108,15 @@ export function handleCreateLocalTerminalImpl(
|
||||
}
|
||||
|
||||
export function handleConnectToHostImpl(getCtx: AppContextGetter, host: Host, hidden = false) {
|
||||
const { addConnectionLog, connectToHost, identities, keys, resolveEffectiveHost, resolveHostAuth, systemInfoRef } = getCtx();
|
||||
const { addConnectionLog, connectToHost, discoveredShells, identities, keys, resolveEffectiveHost, resolveHostAuth, resolveShellSetting, systemInfoRef, terminalSettings } = getCtx();
|
||||
{
|
||||
const { username, hostname: localHost } = systemInfoRef.current;
|
||||
|
||||
const effectiveHost = resolveEffectiveHost(host);
|
||||
const effectiveHost = resolveHostDefaultLocalShell(resolveEffectiveHost(host), {
|
||||
discoveredShells,
|
||||
resolveShellSetting,
|
||||
terminalSettings,
|
||||
});
|
||||
|
||||
// Handle serial hosts separately
|
||||
if (effectiveHost.protocol === 'serial') {
|
||||
@@ -1138,7 +1146,10 @@ export function handleConnectToHostImpl(getCtx: AppContextGetter, host: Host, hi
|
||||
hostId: host.id,
|
||||
hostLabel: host.label,
|
||||
hostname: host.hostname,
|
||||
username: resolvedAuth.username || 'root',
|
||||
// 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'),
|
||||
protocol,
|
||||
...getLogHostVisualSnapshot(effectiveHost),
|
||||
startTime: Date.now(),
|
||||
@@ -1186,8 +1197,13 @@ 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);
|
||||
return Boolean(effective.telnetEnabled) && effective.protocol !== 'telnet';
|
||||
const effectiveProtocol = effective.protocol ?? 'ssh';
|
||||
return effectiveProtocol === 'ssh' && Boolean(effective.telnetEnabled);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -34,6 +34,17 @@ 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 }),
|
||||
@@ -71,8 +82,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, 2);
|
||||
assert.equal((source.match(/layout="overlay"/g) ?? []).length, 2);
|
||||
assert.equal((source.match(/className="pointer-events-auto"/g) ?? []).length, 3);
|
||||
assert.equal((source.match(/layout="overlay"/g) ?? []).length, 3);
|
||||
});
|
||||
|
||||
test('editor host panels share vault resize width persistence', () => {
|
||||
@@ -81,7 +92,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, 2);
|
||||
assert.equal((source.match(/\{\.\.\.hostPanelResizeProps\}/g) ?? []).length, 3);
|
||||
});
|
||||
|
||||
test('editor stays mounted while another app surface is active', () => {
|
||||
|
||||
@@ -5,6 +5,7 @@ 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';
|
||||
@@ -22,14 +23,16 @@ import type {
|
||||
} from '../../types';
|
||||
import { useWorkSurfaceVisible } from './AppHostEditorSurface';
|
||||
|
||||
export type WorkSurfaceHostEditorKind = 'standard' | 'serial';
|
||||
export type WorkSurfaceHostEditorKind = 'standard' | 'serial' | 'local';
|
||||
|
||||
export function resolveWorkSurfaceHostEditorKind(
|
||||
target: WorkSurfaceHostEditorTarget,
|
||||
): WorkSurfaceHostEditorKind {
|
||||
return target.mode === 'edit' && target.openedHost.protocol === 'serial'
|
||||
? 'serial'
|
||||
: 'standard';
|
||||
: target.mode === 'edit' && target.openedHost.protocol === 'local'
|
||||
? 'local'
|
||||
: 'standard';
|
||||
}
|
||||
|
||||
function addGroupAndAncestors(groups: Set<string>, path: string | null | undefined) {
|
||||
@@ -160,7 +163,18 @@ export const AppHostEditorLayer: React.FC<AppHostEditorLayerProps> = ({
|
||||
style={getAppHostEditorLayerStyle(surfaceVisible)}
|
||||
>
|
||||
<PortalContainerProvider container={portalContainer}>
|
||||
{target.mode === 'edit' && target.openedHost.protocol === 'serial' ? (
|
||||
{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' ? (
|
||||
<SerialHostDetailsPanel
|
||||
key={editorKey}
|
||||
initialData={target.openedHost}
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
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,6 +38,7 @@ 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';
|
||||
@@ -270,22 +271,6 @@ 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();
|
||||
|
||||
@@ -1367,8 +1352,32 @@ export function AppSideEffects() {
|
||||
{ validProxyProfileIds: proxyProfileIdSet },
|
||||
)
|
||||
: applyGroupDefaults(host, {}, { validProxyProfileIds: proxyProfileIdSet });
|
||||
return materializeHostProxyProfile(withGroupDefaults, proxyProfiles);
|
||||
}, [groupConfigs, proxyProfileIdSet, proxyProfiles]);
|
||||
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],
|
||||
);
|
||||
|
||||
const createWorkspaceWithEffectiveHosts = useCallback((name: string, selectedHosts: Host[]) => {
|
||||
createWorkspaceWithHosts(name, selectedHosts.map(resolveEffectiveHost));
|
||||
@@ -1399,14 +1408,17 @@ export function AppSideEffects() {
|
||||
return handleConnectToHostImpl(() => ({
|
||||
addConnectionLog,
|
||||
connectToHost,
|
||||
discoveredShells,
|
||||
host,
|
||||
identities,
|
||||
keys,
|
||||
resolveEffectiveHost: effectiveHostResolver,
|
||||
resolveHostAuth,
|
||||
resolveShellSetting,
|
||||
systemInfoRef,
|
||||
terminalSettings,
|
||||
}), host, hidden);
|
||||
}, [addConnectionLog, connectToHost, resolveEffectiveHost, identities, keys]);
|
||||
}, [addConnectionLog, connectToHost, resolveEffectiveHost, identities, keys, discoveredShells, terminalSettings]);
|
||||
|
||||
const handleConnectToHostWithTabName = useCallback((host: Host, tabName?: string) => {
|
||||
const sessionId = handleConnectToHost(host);
|
||||
|
||||
@@ -37,6 +37,7 @@ 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';
|
||||
@@ -354,14 +355,16 @@ 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]);
|
||||
), [groupConfigs, proxyProfiles, validProxyProfileIds, defaultLocalShell]);
|
||||
|
||||
const handleAppendHostToWorkspace = useCallback((workspaceId: string, hostId: string) => {
|
||||
const host = hosts.find((entry) => entry.id === hostId);
|
||||
|
||||
@@ -535,6 +535,8 @@ export const enAiMessages: Messages = {
|
||||
'ai.chat.searchSessionsHint': 'Searches titles, prioritizing newer messages and entries. For long messages and tool outputs, only a limited portion at the beginning is searched. This is not full-text search.',
|
||||
'ai.chat.noMatchingSessions': 'No matches in titles or recent retained content',
|
||||
'ai.chat.noSessions': 'No previous sessions',
|
||||
'ai.chat.forkFromHere': 'Fork from here',
|
||||
'ai.chat.forkCreated': 'Branched into a new chat — the original stays in history.',
|
||||
'ai.chat.retryHint': 'You can retry by sending your message again.',
|
||||
'ai.chat.approvalTimeout': 'Tool approval timed out after 5 minutes. You can retry by sending your message again.',
|
||||
'ai.chat.menuHosts': 'Hosts',
|
||||
|
||||
@@ -1123,6 +1123,7 @@ 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',
|
||||
@@ -1148,6 +1149,9 @@ Highlight the focused split pane:
|
||||
'vault.hosts.deleteMultiple.success': 'Deleted {count} hosts',
|
||||
'vault.hosts.connectSelected': 'Connect ({count})',
|
||||
'vault.hosts.connectMultiple.success': 'Connecting {count} hosts',
|
||||
'vault.hosts.moveSelected': 'Move to group ({count})',
|
||||
'vault.hosts.moveSelected.description': 'Move the selected hosts to an existing group, or leave them ungrouped. Selected groups are not moved.',
|
||||
'vault.hosts.moveMultiple.success': 'Moved {count} hosts to {group}',
|
||||
'vault.hosts.moveToGroup.success': 'Moved {host} to {group}',
|
||||
'vault.hosts.errors.nameRequired': 'Host name is required.',
|
||||
'vault.hosts.empty.title': 'Set up your hosts',
|
||||
|
||||
@@ -1018,6 +1018,21 @@ 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',
|
||||
|
||||
@@ -526,6 +526,8 @@ export const esAiMessages: Messages = {
|
||||
'ai.chat.searchSessionsHint': 'Busca en los títulos y da prioridad a los mensajes y entradas más recientes. De los mensajes largos y las salidas de herramientas, solo se busca una parte limitada del comienzo. No es una búsqueda de texto completo.',
|
||||
'ai.chat.noMatchingSessions': 'Sin coincidencias en títulos ni contenido reciente conservado',
|
||||
'ai.chat.noSessions': 'No hay sesiones anteriores',
|
||||
'ai.chat.forkFromHere': 'Bifurcar desde aquí',
|
||||
'ai.chat.forkCreated': 'Conversación bifurcada en un chat nuevo; el original permanece en el historial.',
|
||||
'ai.chat.retryHint': 'Puedes reintentar enviando tu mensaje de nuevo.',
|
||||
'ai.chat.approvalTimeout': 'La aprobación de la herramienta expiró después de 5 minutos. Puedes reintentar enviando tu mensaje de nuevo.',
|
||||
'ai.chat.menuHosts': 'Hosts',
|
||||
|
||||
@@ -1081,6 +1081,7 @@ 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',
|
||||
@@ -1106,6 +1107,9 @@ Resalta el panel dividido enfocado:
|
||||
'vault.hosts.deleteMultiple.success': 'Se eliminaron {count} hosts',
|
||||
'vault.hosts.connectSelected': 'Conectar ({count})',
|
||||
'vault.hosts.connectMultiple.success': 'Conectando {count} hosts',
|
||||
'vault.hosts.moveSelected': 'Mover al grupo ({count})',
|
||||
'vault.hosts.moveSelected.description': 'Mueve los hosts seleccionados a un grupo existente o los deja sin grupo. Los grupos seleccionados no se mueven.',
|
||||
'vault.hosts.moveMultiple.success': 'Se movieron {count} hosts a {group}',
|
||||
'vault.hosts.moveToGroup.success': 'Se movió {host} a {group}',
|
||||
'vault.hosts.errors.nameRequired': 'Se requiere el nombre del host.',
|
||||
'vault.hosts.empty.title': 'Configura tus hosts',
|
||||
|
||||
@@ -1008,6 +1008,21 @@ 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',
|
||||
|
||||
@@ -482,6 +482,8 @@ export const ruAiMessages: Messages = {
|
||||
'ai.chat.searchSessionsHint': 'Поиск по заголовкам с приоритетом более новых сообщений и записей. В длинных сообщениях и результатах инструментов поиск охватывает только ограниченную начальную часть. Это не полнотекстовый поиск.',
|
||||
'ai.chat.noMatchingSessions': 'Нет совпадений в заголовках и недавнем сохранённом содержимом',
|
||||
'ai.chat.noSessions': 'Предыдущих сессий нет',
|
||||
'ai.chat.forkFromHere': 'Ветвление отсюда',
|
||||
'ai.chat.forkCreated': 'Разговор перенесён в новый чат; оригинал остаётся в истории.',
|
||||
'ai.chat.retryHint': 'Вы можете повторить попытку, отправив сообщение ещё раз.',
|
||||
'ai.chat.approvalTimeout': 'Время ожидания одобрения инструмента истекло через 5 минут. Вы можете повторить попытку, отправив сообщение ещё раз.',
|
||||
'ai.chat.menuHosts': 'Хосты',
|
||||
|
||||
@@ -102,6 +102,7 @@ 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': 'Экспорт',
|
||||
@@ -127,6 +128,9 @@ export const ruVaultMessages: Messages = {
|
||||
'vault.hosts.deleteMultiple.success': 'Удалено хостов: {count}',
|
||||
'vault.hosts.connectSelected': 'Подключить ({count})',
|
||||
'vault.hosts.connectMultiple.success': 'Подключение хостов: {count}',
|
||||
'vault.hosts.moveSelected': 'Переместить в группу ({count})',
|
||||
'vault.hosts.moveSelected.description': 'Переместить выбранные хосты в существующую группу или оставить без группы. Выбранные группы не перемещаются.',
|
||||
'vault.hosts.moveMultiple.success': 'Перемещено {count} хостов в {group}',
|
||||
'vault.hosts.moveToGroup.success': 'Хост {host} перемещён в {group}',
|
||||
'vault.hosts.empty.title': 'Настройте свои хосты',
|
||||
'vault.hosts.empty.desc': 'Сохраняйте хосты, чтобы быстро подключаться к серверам, виртуальным машинам и контейнерам.',
|
||||
@@ -1051,6 +1055,21 @@ 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': 'Провайдеры подключения не установлены',
|
||||
|
||||
@@ -535,6 +535,8 @@ export const zhCNAiMessages: Messages = {
|
||||
'ai.chat.searchSessionsHint': '搜索标题,并优先搜索较新的消息和条目;超长消息及工具输出仅搜索开头的有限部分。这不是全文搜索。',
|
||||
'ai.chat.noMatchingSessions': '标题和近期保留的内容中没有匹配结果',
|
||||
'ai.chat.noSessions': '没有历史会话',
|
||||
'ai.chat.forkFromHere': '从此处分叉',
|
||||
'ai.chat.forkCreated': '已分叉到新对话,原对话保留在历史中。',
|
||||
'ai.chat.retryHint': '你可以重新发送消息来重试。',
|
||||
'ai.chat.approvalTimeout': '工具审批已超时(5 分钟)。你可以重新发送消息来重试。',
|
||||
'ai.chat.menuHosts': '主机',
|
||||
|
||||
@@ -740,6 +740,7 @@ 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': '导出',
|
||||
@@ -765,6 +766,9 @@ export const zhCNCoreMessages: Messages = {
|
||||
'vault.hosts.deleteMultiple.success': '已删除 {count} 个主机',
|
||||
'vault.hosts.connectSelected': '连接 ({count})',
|
||||
'vault.hosts.connectMultiple.success': '正在连接 {count} 个主机',
|
||||
'vault.hosts.moveSelected': '移动到分组 ({count})',
|
||||
'vault.hosts.moveSelected.description': '将选中的主机移动到现有分组,也可移出分组。选中的分组本身不会移动。',
|
||||
'vault.hosts.moveMultiple.success': '已将 {count} 个主机移动到 {group}',
|
||||
'vault.hosts.moveToGroup.success': '已将 {host} 移动到 {group}',
|
||||
'vault.hosts.errors.nameRequired': '主机名称不能为空。',
|
||||
'vault.hosts.empty.title': '设置你的主机',
|
||||
|
||||
@@ -390,6 +390,21 @@ 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': '未安装连接提供程序',
|
||||
|
||||
@@ -525,6 +525,8 @@ export const zhTWAiMessages: Messages = {
|
||||
'ai.chat.searchSessionsHint': '搜尋標題,並優先搜尋較新的訊息與項目;超長訊息與工具輸出僅搜尋開頭的有限部分。這不是全文搜尋。',
|
||||
'ai.chat.noMatchingSessions': '標題和近期保留的內容中沒有符合的結果',
|
||||
'ai.chat.noSessions': '沒有歷史工作階段',
|
||||
'ai.chat.forkFromHere': '從此處分叉',
|
||||
'ai.chat.forkCreated': '已分叉到新對話,原對話保留在歷史中。',
|
||||
'ai.chat.retryHint': '你可以重新發送訊息來重試。',
|
||||
'ai.chat.approvalTimeout': '工具核准已超時(5 分鐘)。你可以重新發送訊息來重試。',
|
||||
'ai.chat.menuHosts': '主機',
|
||||
|
||||
@@ -742,6 +742,7 @@ 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': '匯出',
|
||||
@@ -767,6 +768,9 @@ export const zhTWCoreMessages: Messages = {
|
||||
'vault.hosts.deleteMultiple.success': '已刪除 {count} 個主機',
|
||||
'vault.hosts.connectSelected': '連線 ({count})',
|
||||
'vault.hosts.connectMultiple.success': '正在連線 {count} 個主機',
|
||||
'vault.hosts.moveSelected': '移動到群組 ({count})',
|
||||
'vault.hosts.moveSelected.description': '將選取的主機移動到現有群組,也可移出群組。選取的群組本身不會移動。',
|
||||
'vault.hosts.moveMultiple.success': '已將 {count} 個主機移動到 {group}',
|
||||
'vault.hosts.moveToGroup.success': '已將 {host} 移動到 {group}',
|
||||
'vault.hosts.errors.nameRequired': '主機名稱不能為空。',
|
||||
'vault.hosts.empty.title': '設定你的主機',
|
||||
|
||||
@@ -390,6 +390,21 @@ 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': '尚未安裝連線提供者',
|
||||
|
||||
@@ -0,0 +1,244 @@
|
||||
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 } from "../../domain/terminalHostResolution";
|
||||
import { resolveEffectiveTerminalHost, resolveTerminalSessionHost } from "../../domain/terminalHostResolution";
|
||||
import {
|
||||
createHostTerminalSession,
|
||||
createSerialTerminalSession,
|
||||
@@ -171,3 +171,16 @@ 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,4 +1,5 @@
|
||||
import type { Host, SerialConfig, TerminalSession } from "../../domain/models";
|
||||
import { classifyLocalShellType } from "../../lib/localShell";
|
||||
|
||||
export interface LocalTerminalOptions {
|
||||
shellType?: TerminalSession["shellType"];
|
||||
@@ -18,6 +19,28 @@ 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,
|
||||
@@ -113,6 +136,30 @@ 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,3 +224,31 @@ 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,4 +1,5 @@
|
||||
import type { TerminalSession } from "../../domain/models";
|
||||
import { classifyLocalShellType } from "../../lib/localShell";
|
||||
|
||||
type SftpReuseCandidate = Pick<
|
||||
TerminalSession,
|
||||
@@ -44,7 +45,12 @@ function getClonedShellType(
|
||||
session: TerminalSession,
|
||||
localShellType?: TerminalSession["shellType"],
|
||||
): TerminalSession["shellType"] {
|
||||
return session.protocol === "local" ? localShellType : session.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;
|
||||
}
|
||||
|
||||
function createTerminalSessionClone(
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
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 } 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 SOURCE_SESSION: AISession = {
|
||||
id: 'chat-source',
|
||||
title: 'Fork 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 ForkHarness({ 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(ForkHarness, { capture }));
|
||||
});
|
||||
return { dom, container, root, capture };
|
||||
}
|
||||
|
||||
test('fork retains the completed prefix, replay metadata, and source-owned outputs', async () => {
|
||||
const runtime = getAgentRuntime();
|
||||
const outputStore = runtime.getToolOutputStore('chat-source');
|
||||
const handle = outputStore.store({ chatSessionId: 'chat-source', capabilityId: 'terminal.exec', content: 'saved output' });
|
||||
runtime.getSessionStateStore().updateFromToolResult('chat-source', 'terminal_start',
|
||||
{ sessionId: 'terminal-a', command: 'running-command' },
|
||||
JSON.stringify({ jobId: 'source-job', status: 'running', nextOffset: 7 }), false);
|
||||
const source = structuredClone(SOURCE_SESSION);
|
||||
source.externalSessionId = 'original-provider-thread';
|
||||
source.messages[1].content = `Preview handleId=${handle.id}`;
|
||||
source.messages[1].providerContinuation = { reasoningParts: [{ text: 'checked', providerOptions: { anthropic: { signature: 'signed-thinking' } } }] };
|
||||
const { dom, container, root, capture } = await setupAiState([source]);
|
||||
let branch: AISession | null = null;
|
||||
await act(async () => { branch = await capture.ai!.forkSessionFromMessage(source.id, 'assistant-1', { type: 'terminal', targetId: 'reconnected' }); });
|
||||
assert.ok(branch);
|
||||
assert.notEqual(branch!.id, source.id);
|
||||
assert.equal(branch!.externalSessionId, undefined);
|
||||
assert.deepEqual(branch!.messages[1].providerContinuation, source.messages[1].providerContinuation);
|
||||
assert.match(branch!.messages[1].content, /Preview/);
|
||||
assert.doesNotMatch(branch!.messages[1].content, /handleId=/);
|
||||
assert.deepEqual(branch!.scope, { type: 'terminal', targetId: 'reconnected', hostIds: undefined });
|
||||
assert.equal(capture.activeSessionIdMap['terminal:reconnected'], branch!.id);
|
||||
assert.deepEqual({ ...capture.sessions.find(item => item.id === source.id), updatedAt: source.updatedAt }, source);
|
||||
assert.equal(outputStore.read({ handleId: handle.id }, branch!.id), null);
|
||||
assert.equal(outputStore.read({ handleId: handle.id }, source.id), 'saved output');
|
||||
assert.deepEqual(runtime.getSessionStateStore().get(branch!.id).activeJobs, {});
|
||||
assert.ok(runtime.getSessionStateStore().get(source.id).activeJobs['source-job']);
|
||||
runtime.getSessionStateStore().clear(source.id);
|
||||
outputStore.prune(source.id);
|
||||
await act(async () => root.unmount()); container.remove(); dom.window.close();
|
||||
});
|
||||
|
||||
test('fork rejects an incomplete boundary without adding a chat', async () => {
|
||||
const source = structuredClone(SOURCE_SESSION);
|
||||
source.messages[1].executionStatus = 'failed';
|
||||
const { dom, container, root, capture } = await setupAiState([source]);
|
||||
let result: unknown;
|
||||
await act(async () => { result = await capture.ai!.forkSessionFromMessage(source.id, 'assistant-1'); });
|
||||
assert.equal(result, null);
|
||||
assert.equal(capture.sessions.length, 1);
|
||||
await act(async () => root.unmount()); container.remove(); dom.window.close();
|
||||
});
|
||||
|
||||
for (const newerCount of [49, 50]) {
|
||||
test(`fork preserves the original on 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 branch: AISession | null = null;
|
||||
await act(async () => { branch = await capture.ai!.forkSessionFromMessage(SOURCE_SESSION.id, 'assistant-1'); });
|
||||
assert.ok(branch);
|
||||
const saved: AISession[] = JSON.parse(dom.window.localStorage.getItem('netcatty_ai_sessions_v1')!);
|
||||
assert.equal(saved.length, 50);
|
||||
assert.deepEqual(saved.find(item => item.id === SOURCE_SESSION.id)?.messages, SOURCE_SESSION.messages);
|
||||
assert.deepEqual(saved.find(item => item.id === branch!.id)?.messages, SOURCE_SESSION.messages.slice(0, 2));
|
||||
await act(async () => root.unmount()); container.remove(); dom.window.close();
|
||||
const reloaded = await setupAiState(saved);
|
||||
assert.equal(reloaded.capture.sessions.find(item => item.id === SOURCE_SESSION.id)?.messages.length, 4);
|
||||
assert.equal(reloaded.capture.sessions.find(item => item.id === branch!.id)?.messages.length, 2);
|
||||
await act(async () => reloaded.root.unmount()); reloaded.container.remove(); reloaded.dom.window.close();
|
||||
});
|
||||
}
|
||||
@@ -55,6 +55,7 @@ import {
|
||||
import { convertFilesToUploads } from './useFileUpload';
|
||||
import { removeProviderReferences } from './aiProviderCleanup';
|
||||
import { publishAISessionsSnapshot } from './aiSessionsStore';
|
||||
import { planSessionFork } from '../../domain/aiSessionFork';
|
||||
import {
|
||||
AI_STATE_CHANGED_DRAFTS_BY_SCOPE,
|
||||
AI_STATE_CHANGED_PANEL_VIEW_BY_SCOPE,
|
||||
@@ -680,6 +681,57 @@ export function useAIState() {
|
||||
};
|
||||
}, [persistSessions]);
|
||||
|
||||
// "Fork from here": copy the conversation up to a completed assistant
|
||||
// response into a new session and make it the scope's active chat. The
|
||||
// original session stays untouched in history. Pure boundary validation and
|
||||
// plan building live in domain/aiSessionFork; the fork has no original
|
||||
// external session id or pending-turn state. Per-message replay metadata is
|
||||
// preserved; output handles and running jobs remain owned by the source.
|
||||
// Null is returned when the boundary is not forkable.
|
||||
// `viewingScope` is the panel scope the fork was requested from; the branch
|
||||
// is created there so the fork is active and visible in the very scope that
|
||||
// is viewing it, even when the source session carries a stale scope (e.g. a
|
||||
// terminal chat resumed in a reconnected terminal or a merged workspace).
|
||||
const forkSessionFromMessage = useCallback(async (
|
||||
sessionId: string,
|
||||
messageId: string,
|
||||
viewingScope?: Pick<AISessionScope, 'type' | 'targetId'>,
|
||||
): Promise<AISession | null> => {
|
||||
const source = sessionsRef.current.find(s => s.id === sessionId);
|
||||
if (!source) return null;
|
||||
const plan = planSessionFork(source, messageId);
|
||||
if (!plan.ok) return null;
|
||||
const now = Date.now();
|
||||
const fork: AISession = {
|
||||
id: `ai_${now}_${Math.random().toString(36).slice(2, 8)}`,
|
||||
title: plan.title,
|
||||
agentId: source.agentId,
|
||||
scope: {
|
||||
...source.scope,
|
||||
// Host membership stays with the source (the messages were produced
|
||||
// there); the scope the fork belongs to is the viewing scope.
|
||||
...(viewingScope ? { type: viewingScope.type, targetId: viewingScope.targetId } : {}),
|
||||
hostIds: source.scope.hostIds ? [...source.scope.hostIds] : undefined,
|
||||
},
|
||||
messages: plan.messages,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
if (plan.contextCompaction) {
|
||||
fork.contextCompaction = plan.contextCompaction;
|
||||
}
|
||||
setSessionsRaw(prev => {
|
||||
// Keep the source alongside its branch within the existing history limit.
|
||||
const next = [fork, { ...source, updatedAt: now }, ...prev.filter(session => session.id !== source.id)];
|
||||
setLatestAISessionsSnapshot(next);
|
||||
persistSessions(next);
|
||||
return next;
|
||||
});
|
||||
const scopeKey = `${fork.scope.type}:${fork.scope.targetId ?? ''}`;
|
||||
setActiveSessionId(scopeKey, fork.id);
|
||||
return fork;
|
||||
}, [persistSessions, setActiveSessionId]);
|
||||
|
||||
const createSession = useCallback((scope: AISessionScope, agentId?: string): AISession => {
|
||||
const now = Date.now();
|
||||
const session: AISession = {
|
||||
@@ -718,7 +770,7 @@ export function useAIState() {
|
||||
const branched = result.session;
|
||||
setSessionsRaw(prev => {
|
||||
// Keep both recently used chats within the existing history limit.
|
||||
const next = [branched, source, ...prev.filter(session => session.id !== source.id)];
|
||||
const next = [branched, { ...source, updatedAt: branched.updatedAt }, ...prev.filter(session => session.id !== source.id)];
|
||||
setLatestAISessionsSnapshot(next);
|
||||
persistSessions(next);
|
||||
return next;
|
||||
@@ -1319,6 +1371,7 @@ export function useAIState() {
|
||||
seedWorkspaceActiveSessionFromMembers,
|
||||
handoffDissolvedWorkspaceScope,
|
||||
retargetWorkspaceActiveChatForMemberLoss,
|
||||
forkSessionFromMessage,
|
||||
}), [
|
||||
providers,
|
||||
setProviders,
|
||||
@@ -1380,5 +1433,6 @@ export function useAIState() {
|
||||
seedWorkspaceActiveSessionFromMembers,
|
||||
handoffDissolvedWorkspaceScope,
|
||||
retargetWorkspaceActiveChatForMemberLoss,
|
||||
forkSessionFromMessage,
|
||||
]);
|
||||
}
|
||||
@@ -155,7 +155,7 @@ test('undo copies chat only, preserving original outputs and job ownership', asy
|
||||
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)), original);
|
||||
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);
|
||||
@@ -163,17 +163,32 @@ test('undo copies chat only, preserving original outputs and job ownership', asy
|
||||
container.remove(); dom.window.close();
|
||||
});
|
||||
|
||||
test('undo keeps an old source beside its copy within ordinary history retention', async () => {
|
||||
const sources = Array.from({ length: 50 }, (_, index) => ({
|
||||
...structuredClone(SOURCE_SESSION), id: `history-${index}`,
|
||||
}));
|
||||
const { dom, container, root, capture } = await setupAiState(sources);
|
||||
await act(async () => { await capture.ai!.undoLastTurnInSession('history-49'); });
|
||||
assert.equal(capture.sessions[1].id, 'history-49');
|
||||
await new Promise(resolve => setTimeout(resolve, 350));
|
||||
const saved = JSON.parse(dom.window.localStorage.getItem('netcatty_ai_sessions_v1')!);
|
||||
assert.ok(saved.some((item: AISession) => item.id === 'history-49'));
|
||||
assert.deepEqual(saved.find((item: AISession) => item.id === 'history-49').messages, sources[49].messages);
|
||||
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();
|
||||
});
|
||||
}
|
||||
@@ -2,11 +2,13 @@ import assert from 'node:assert/strict';
|
||||
import { test } from 'node:test';
|
||||
import React from 'react';
|
||||
import { act, create, type ReactTestRenderer } from 'react-test-renderer';
|
||||
import type { Host, Identity, SSHKey, TerminalSession } from '../../domain/models';
|
||||
import type { GroupConfig, Host, Identity, SSHKey, TerminalSession } from '../../domain/models';
|
||||
import { updateRestoredSessionStatusState } from './sessionRestoreState';
|
||||
import { resolveTerminalSessionHost } from '../../domain/terminalHostResolution';
|
||||
import { resolveEffectiveTerminalHost, resolveTerminalSessionHost } from '../../domain/terminalHostResolution';
|
||||
import { createHostTerminalSession } from './sessionFactories';
|
||||
import { 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;
|
||||
@@ -14,14 +16,14 @@ const host = (extra: Partial<Host> = {}): Host => ({
|
||||
id: 'host', hostname: 'original.invalid', username: 'user', label: 'Original', os: 'linux', tags: [], protocol: 'ssh', ...extra,
|
||||
});
|
||||
|
||||
async function harness(initial: Host, extra: { hosts?: Host[]; keys?: SSHKey[]; identities?: Identity[]; sessions?: TerminalSession[] } = {}) {
|
||||
async function harness(initial: Host, extra: { hosts?: Host[]; keys?: SSHKey[]; identities?: Identity[]; sessions?: TerminalSession[]; resolveEffectiveHost?: (host: Host) => Host } = {}) {
|
||||
let hosts = [initial, ...(extra.hosts ?? [])];
|
||||
let keys = extra.keys ?? [];
|
||||
let identities = extra.identities ?? [];
|
||||
let sessions: TerminalSession[] = extra.sessions ?? [{ ...createHostTerminalSession('tab', initial), status: 'connected' }];
|
||||
let find: ReturnType<typeof useHostOpenSessionReuse>;
|
||||
function Harness() {
|
||||
find = useHostOpenSessionReuse({ sessions, hosts, keys, identities, resolveEffectiveHost: effective });
|
||||
find = useHostOpenSessionReuse({ sessions, hosts, keys, identities, resolveEffectiveHost: extra.resolveEffectiveHost ?? effective });
|
||||
return null;
|
||||
}
|
||||
let renderer: ReactTestRenderer;
|
||||
@@ -146,3 +148,236 @@ test('an unchanged restored plugin configuration can be reused after manual reco
|
||||
await api.update({ sessions: updateRestoredSessionStatusState([restored], restored.id, 'connecting') });
|
||||
assert.equal((await api.find())?.sessionId, 'tab');
|
||||
});
|
||||
|
||||
for (const [field, value] of [
|
||||
['telnetPort', 2324], ['telnetUsername', 'other-telnet-user'], ['telnetPassword', 'rotated-telnet-password'],
|
||||
] as const) {
|
||||
test(`a Telnet ${field} edit cannot reuse the old transport`, async (t) => {
|
||||
const original = host({ protocol: 'telnet', telnetPort: 2323, telnetUsername: 'telnet-user', telnetPassword: 'telnet-password' });
|
||||
const api = await harness(original);
|
||||
t.after(api.close);
|
||||
assert.equal((await api.find())?.sessionId, 'tab');
|
||||
await api.update({ host: { ...original, [field]: value } });
|
||||
assert.equal(await api.find(), undefined);
|
||||
});
|
||||
}
|
||||
|
||||
test('Telnet identity credential rotation and missing identity reject reuse', async (t) => {
|
||||
const identity: Identity = { id: 'telnet-login', label: 'Telnet login', username: 'telnet-user', password: 'original', authMethod: 'password', created: 0 };
|
||||
const original = host({ protocol: 'telnet', telnetIdentityId: identity.id });
|
||||
const api = await harness(original, { identities: [identity] });
|
||||
t.after(api.close);
|
||||
assert.equal((await api.find())?.sessionId, 'tab');
|
||||
for (const identities of [[{ ...identity, username: 'other-user' }], [{ ...identity, password: 'rotated' }], []]) {
|
||||
await api.update({ identities });
|
||||
assert.equal(await api.find(), undefined);
|
||||
}
|
||||
});
|
||||
|
||||
test('Telnet explicit empty login does not fall back to SSH login fields', async (t) => {
|
||||
const original = host({ protocol: 'telnet', telnetUsername: '', telnetPassword: '', password: 'ssh-only' });
|
||||
const api = await harness(original);
|
||||
t.after(api.close);
|
||||
assert.equal((await api.find())?.sessionId, 'tab');
|
||||
await api.update({ host: { ...original, label: 'Renamed', username: 'unrelated-ssh-user', password: 'unrelated-ssh-password', identityId: 'missing-ssh-identity', telnetPort: 23 } });
|
||||
assert.equal((await api.find())?.sessionId, 'tab');
|
||||
await api.update({ host: { ...original, telnetUsername: undefined, telnetPassword: undefined } });
|
||||
assert.equal(await api.find(), undefined, 'removing explicit empty overrides enables the actual fallback login');
|
||||
});
|
||||
|
||||
for (const transport of [{ protocol: 'et' as const }, { protocol: 'ssh' as const, etEnabled: true }]) {
|
||||
test(`changing a jump ET server port rejects the old ${JSON.stringify(transport)} transport`, async (t) => {
|
||||
const original = host({ ...transport, hostChain: { hostIds: ['jump'] } });
|
||||
const jump = host({ id: 'jump', hostname: 'jump.invalid', etPort: 2022 });
|
||||
const api = await harness(original, { hosts: [jump] });
|
||||
t.after(api.close);
|
||||
assert.equal((await api.find())?.sessionId, 'tab');
|
||||
await api.update({ hosts: [original, { ...jump, etPort: 3022 }] });
|
||||
assert.equal(await api.find(), undefined);
|
||||
});
|
||||
}
|
||||
|
||||
test('serial direct auto-login password is not masked by an unrelated SSH identity', async (t) => {
|
||||
const identity: Identity = { id: 'ssh-login', label: 'SSH login', username: 'ssh-user', password: 'ssh-password', authMethod: 'password', created: 0 };
|
||||
const original = host({ protocol: 'serial', hostname: '/dev/tty.test', username: '', password: 'serial-password', identityId: identity.id });
|
||||
const api = await harness(original, { identities: [identity] });
|
||||
t.after(api.close);
|
||||
assert.equal((await api.find())?.sessionId, 'tab');
|
||||
await api.update({ host: { ...original, password: 'rotated-serial-password' } });
|
||||
assert.equal(await api.find(), undefined);
|
||||
});
|
||||
|
||||
test('unchanged serial direct username matches the actual auto-login input', async (t) => {
|
||||
const original = host({ protocol: 'serial', hostname: '/dev/tty.test', username: 'serial-user', password: 'serial-password' });
|
||||
const api = await harness(original);
|
||||
t.after(api.close);
|
||||
assert.equal((await api.find())?.sessionId, 'tab');
|
||||
await api.update({ host: { ...original, username: 'other-serial-user' } });
|
||||
assert.equal(await api.find(), undefined);
|
||||
});
|
||||
|
||||
for (const [field, value] of [
|
||||
['localShell', '/bin/bash'], ['localShellArgs', ['--norc']], ['localStartDir', '/replacement'],
|
||||
] as const) {
|
||||
test(`a saved local ${field} edit cannot reuse the old process`, async (t) => {
|
||||
const original = host({ protocol: 'local', hostname: 'localhost', localShell: '/bin/zsh', localShellArgs: ['-l'], localStartDir: '/original' });
|
||||
const session = { ...createHostTerminalSession('tab', original), status: 'connected', localShell: original.localShell, localShellArgs: original.localShellArgs, localStartDir: original.localStartDir } as TerminalSession;
|
||||
const api = await harness(original, { sessions: [session] });
|
||||
t.after(api.close);
|
||||
assert.equal((await api.find())?.sessionId, 'tab');
|
||||
await api.update({ host: { ...original, [field]: value } });
|
||||
assert.equal(await api.find(), undefined);
|
||||
const alreadyEdited = await harness({ ...original, [field]: value }, { sessions: [session] });
|
||||
t.after(alreadyEdited.close);
|
||||
assert.equal(await alreadyEdited.find(), undefined, 'opening fields come from the actual local session');
|
||||
});
|
||||
}
|
||||
|
||||
test('local default shell, 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,6 +1,10 @@
|
||||
import { useCallback, useLayoutEffect, useRef } from 'react';
|
||||
import type { Host, Identity, SSHKey, TerminalSession } from '../../domain/models';
|
||||
import { findReusableHostSession } from '../../domain/hostOpenSession';
|
||||
import { resolveTelnetPassword, resolveTelnetPort, resolveTelnetUsername } from '../../domain/host';
|
||||
import { isEncryptedCredentialPlaceholder, sanitizeCredentialValue } from '../../domain/credentials';
|
||||
import { resolveEffectiveTerminalProtocol } from '../../domain/terminalProtocol';
|
||||
import { findIncompleteProxyIdentityId, findMissingProxyIdentityId, hasUsableProxyConfig } from '../../domain/proxyProfiles';
|
||||
import { buildSftpHostCredentials } from './sftp/useSftpHostCredentials';
|
||||
import { buildTransferPoolKey } from './sftp/transferConnectionPool';
|
||||
import { createHostTerminalSession } from './sessionFactories';
|
||||
@@ -13,13 +17,52 @@ type HostOpenSessionReuseInput = {
|
||||
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, Promise<string | undefined>>());
|
||||
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,
|
||||
});
|
||||
@@ -28,13 +71,17 @@ export function useHostOpenSessionReuse({ sessions, hosts, keys, identities, res
|
||||
for (const hop of options.jumpHosts ?? []) delete hop.label;
|
||||
const connectionOptions = {
|
||||
...options,
|
||||
protocol: host.protocol || 'ssh',
|
||||
protocol,
|
||||
moshEnabled: host.moshEnabled,
|
||||
moshServerPath: host.moshServerPath,
|
||||
etEnabled: host.etEnabled,
|
||||
etPort: host.etPort,
|
||||
etJumpPorts: protocol === 'et'
|
||||
? host.hostChain?.hostIds.map((id) => hosts.find((item) => item.id === id)).map((hop) => hop && (resolveEffectiveHost(hop).etPort ?? 2022))
|
||||
: undefined,
|
||||
pluginConnection: host.pluginConnection,
|
||||
serialConfig: host.protocol === 'serial' ? createHostTerminalSession('', host).serialConfig : undefined,
|
||||
serialConfig: protocol === 'serial' ? createHostTerminalSession('', host).serialConfig : undefined,
|
||||
serialLogin: protocol === 'serial' ? { username: host.username?.trim(), password: sanitizeCredentialValue(host.password) } : undefined,
|
||||
agentForwarding: host.agentForwarding,
|
||||
x11Forwarding: host.x11Forwarding,
|
||||
};
|
||||
@@ -51,19 +98,29 @@ export function useHostOpenSessionReuse({ sessions, hosts, keys, identities, res
|
||||
const host = hosts.find((candidate) => candidate.id === session.hostId);
|
||||
if (!host) continue;
|
||||
try {
|
||||
openingKeys.current.set(session.id, connectionKey({
|
||||
...resolveEffectiveHost(host),
|
||||
const effectiveHost = resolveEffectiveHost(host);
|
||||
const openingHost = {
|
||||
...effectiveHost,
|
||||
hostname: session.hostname,
|
||||
username: session.username,
|
||||
username: session.protocol === 'serial' ? resolveEffectiveHost(host).username : session.username,
|
||||
port: session.port ?? host.port,
|
||||
protocol: session.protocol ?? host.protocol,
|
||||
moshEnabled: session.moshEnabled ?? host.moshEnabled,
|
||||
etEnabled: session.etEnabled ?? host.etEnabled,
|
||||
serialConfig: session.serialConfig ?? host.serialConfig,
|
||||
pluginConnection: session.pluginConnection ?? host.pluginConnection,
|
||||
}).catch(() => undefined));
|
||||
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, Promise.resolve(undefined));
|
||||
openingKeys.current.set(session.id, { key: Promise.resolve(undefined) });
|
||||
}
|
||||
}
|
||||
}, [sessions, hosts, connectionKey, resolveEffectiveHost]);
|
||||
@@ -73,9 +130,16 @@ export function useHostOpenSessionReuse({ sessions, hosts, keys, identities, res
|
||||
if (!host) return undefined;
|
||||
try {
|
||||
const requestedKey = await connectionKey(resolveEffectiveHost(host));
|
||||
const matches = await Promise.all(sessions.map(async (session) => (
|
||||
session.hostId === hostId && await openingKeys.current.get(session.id) === requestedKey ? session : undefined
|
||||
)));
|
||||
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.
|
||||
|
||||
@@ -1098,17 +1098,12 @@ export const useSessionState = ({
|
||||
if (targetHosts.length === 0) return;
|
||||
const resolvedCommand = commandOverride ?? snippet.command;
|
||||
|
||||
// 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
|
||||
}));
|
||||
// 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),
|
||||
);
|
||||
|
||||
const sessionIds = newSessions.map(s => s.id);
|
||||
|
||||
|
||||
@@ -47,6 +47,8 @@ interface AIChatPanelContentProps {
|
||||
activeSessionId: string | null;
|
||||
handleSelectSession: (sessionId: string) => void;
|
||||
handleDeleteSession: (event: React.MouseEvent, sessionId: string) => void;
|
||||
/** Fork the conversation up to a completed assistant response into a new session. */
|
||||
onForkFromMessage?: (messageId: string) => void;
|
||||
messages: ChatMessage[];
|
||||
isStreaming: boolean;
|
||||
activeCompaction?: import('../application/state/useAgentCompactionUi').ActiveCompactionUi | null;
|
||||
@@ -127,6 +129,7 @@ export const AIChatPanelContent: React.FC<AIChatPanelContentProps> = ({
|
||||
activeSessionId,
|
||||
handleSelectSession,
|
||||
handleDeleteSession,
|
||||
onForkFromMessage,
|
||||
messages,
|
||||
isStreaming,
|
||||
activeCompaction = null,
|
||||
@@ -313,6 +316,8 @@ export const AIChatPanelContent: React.FC<AIChatPanelContentProps> = ({
|
||||
isStreaming={isStreaming}
|
||||
activeSessionId={activeSessionId}
|
||||
activeCompaction={activeCompaction}
|
||||
contextCompaction={activeSession?.contextCompaction}
|
||||
onForkFromMessage={onForkFromMessage}
|
||||
notes={notes}
|
||||
hosts={hosts}
|
||||
onOpenVaultNote={onOpenVaultNote}
|
||||
|
||||
@@ -278,6 +278,7 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
|
||||
updateLastMessage,
|
||||
updateMessageById,
|
||||
persistContextCompaction,
|
||||
forkSessionFromMessage,
|
||||
providers,
|
||||
activeProviderId,
|
||||
activeModelId,
|
||||
@@ -1854,6 +1855,36 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
|
||||
[setActiveSessionId, showScopeSessionView],
|
||||
);
|
||||
|
||||
// "Fork from here": copy the viewed conversation up to a completed assistant
|
||||
// response into a new session, switch to it, and confirm via toast. The
|
||||
// original stays in history. Boundary validation is re-run by the state
|
||||
// hook; a null result means the fork was refused (already hidden in the UI).
|
||||
// This panel's scope is passed so the branch is created in the scope viewing
|
||||
// it, even when the source session's own scope went stale (e.g. a terminal
|
||||
// chat resumed after reconnecting under a new terminal id).
|
||||
const handleForkFromMessage = useCallback(async (messageId: string) => {
|
||||
const sourceSessionId = activeSessionRef.current?.id ?? activeSessionId;
|
||||
if (!sourceSessionId) return;
|
||||
// Reject a double-click while the new session is being selected so one
|
||||
// interaction cannot create two forks.
|
||||
if (!tryBeginSendForKey(`fork:${sourceSessionId}`)) return;
|
||||
try {
|
||||
const fork = await forkSessionFromMessage?.(sourceSessionId, messageId, {
|
||||
type: scopeType,
|
||||
targetId: scopeTargetId,
|
||||
});
|
||||
if (!fork) return;
|
||||
applyHistorySessionSelection(fork.id, {
|
||||
showSessionView: showScopeSessionView,
|
||||
setActiveSessionId,
|
||||
closeHistory: () => setShowHistory(false),
|
||||
});
|
||||
toast.success(t('ai.chat.forkCreated'));
|
||||
} finally {
|
||||
endSendForKey(`fork:${sourceSessionId}`);
|
||||
}
|
||||
}, [activeSessionId, forkSessionFromMessage, scopeTargetId, scopeType, setActiveSessionId, showScopeSessionView, t]);
|
||||
|
||||
const handleDeleteSession = useCallback(
|
||||
async (e: React.MouseEvent, sessionId: string) => {
|
||||
e.stopPropagation();
|
||||
@@ -1940,6 +1971,7 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
|
||||
activeSessionId={activeSessionId}
|
||||
handleSelectSession={handleSelectSession}
|
||||
handleDeleteSession={handleDeleteSession}
|
||||
onForkFromMessage={handleForkFromMessage}
|
||||
messages={messages}
|
||||
isStreaming={isStreaming}
|
||||
activeCompaction={
|
||||
@@ -2030,6 +2062,7 @@ const AI_CHAT_SIDE_PANEL_AI_STATE_KEYS = [
|
||||
'updateLastMessage',
|
||||
'updateMessageById',
|
||||
'persistContextCompaction',
|
||||
'forkSessionFromMessage',
|
||||
'providers',
|
||||
'activeProviderId',
|
||||
'activeModelId',
|
||||
|
||||
@@ -58,6 +58,11 @@ export interface AIChatSidePanelProps {
|
||||
sessionId: string,
|
||||
compaction: import('../infrastructure/ai/types').AISessionContextCompaction,
|
||||
) => void;
|
||||
forkSessionFromMessage?: (
|
||||
sessionId: string,
|
||||
messageId: string,
|
||||
viewingScope?: Pick<AISessionScope, 'type' | 'targetId'>,
|
||||
) => Promise<AISession | null>;
|
||||
// Provider config
|
||||
providers: ProviderConfig[];
|
||||
activeProviderId: string;
|
||||
|
||||
@@ -25,6 +25,7 @@ import {
|
||||
formatProxyConfigType,
|
||||
updateProxyConfigField,
|
||||
} from "../domain/proxyProfiles";
|
||||
import { canServeAsSshJumpHost } from "../domain/terminalProtocol";
|
||||
import {
|
||||
EnvVar,
|
||||
GroupConfig,
|
||||
@@ -333,7 +334,12 @@ const GroupDetailsPanel: React.FC<GroupDetailsPanelPropsWithResize> = ({
|
||||
|
||||
const availableHostsForChain = useMemo(() => {
|
||||
const chainedIds = new Set(form.hostChain?.hostIds || []);
|
||||
return allHosts.filter((h) => !chainedIds.has(h.id));
|
||||
// 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),
|
||||
);
|
||||
}, [allHosts, form.hostChain?.hostIds]);
|
||||
|
||||
const addHostToChain = (hostId: string) => {
|
||||
|
||||
@@ -23,6 +23,7 @@ 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,
|
||||
@@ -749,7 +750,13 @@ const HostDetailsPanel: React.FC<HostDetailsPanelPropsWithResize> = ({
|
||||
|
||||
const availableHostsForChain = useMemo(() => {
|
||||
const chainedIds = new Set(form.hostChain?.hostIds || []);
|
||||
return allHosts.filter((h) => h.id !== form.id && !chainedIds.has(h.id));
|
||||
// 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),
|
||||
);
|
||||
}, [allHosts, form.id, form.hostChain?.hostIds]);
|
||||
|
||||
const chainedHosts = useMemo(() => {
|
||||
|
||||
@@ -16,6 +16,7 @@ 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';
|
||||
@@ -726,6 +727,7 @@ const HostTreeItem: React.FC<HostTreeItemProps> = ({
|
||||
initialTreeItemKey,
|
||||
onActiveTreeItemChange,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const safeHost = sanitizeHost(host);
|
||||
const tags = host.tags || [];
|
||||
const displayDetails = useMemo(
|
||||
@@ -843,7 +845,9 @@ const HostTreeItem: React.FC<HostTreeItemProps> = ({
|
||||
<HostNotesIndicator notes={host.notes} />
|
||||
</div>
|
||||
<div className="truncate text-[11px] leading-4 text-muted-foreground">
|
||||
{displayUsername}@{host.hostname}:{displayPort}
|
||||
{displayProtocol === 'local'
|
||||
? getVaultHostRowSubtitle(safeHost, { localFallback: t('localShell.subtitle') })
|
||||
: `${displayUsername}@${host.hostname}:${displayPort}`}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
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 \?\?/);
|
||||
});
|
||||
@@ -0,0 +1,255 @@
|
||||
/**
|
||||
* 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,6 +30,8 @@ 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> = ({
|
||||
@@ -56,6 +58,7 @@ const SelectHostPanel: React.FC<SelectHostPanelProps> = ({
|
||||
resizable = false,
|
||||
persistWidthStorageKey,
|
||||
resizeAriaLabel,
|
||||
allowLocalHosts = false,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const [newHostPanelOpen, setNewHostPanelOpen] = useState(false);
|
||||
@@ -103,6 +106,7 @@ const SelectHostPanel: React.FC<SelectHostPanelProps> = ({
|
||||
onSaveHost={onSaveHost}
|
||||
onCreateGroup={onCreateGroup}
|
||||
onNewHostPanelOpenChange={setNewHostPanelOpen}
|
||||
allowLocalHosts={allowLocalHosts}
|
||||
className="flex-1 min-h-0"
|
||||
/>
|
||||
</AsidePanel>
|
||||
|
||||
@@ -58,6 +58,8 @@ 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;
|
||||
}
|
||||
|
||||
@@ -85,6 +87,7 @@ export const SelectHostPanelContent: React.FC<SelectHostPanelContentProps> = ({
|
||||
onSaveHost,
|
||||
onCreateGroup,
|
||||
onNewHostPanelOpenChange,
|
||||
allowLocalHosts = false,
|
||||
className,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
@@ -106,9 +109,14 @@ 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'),
|
||||
[hosts],
|
||||
() => hosts.filter((host) => host.protocol !== 'serial' && (allowLocalHosts || host.protocol !== 'local')),
|
||||
[hosts, allowLocalHosts],
|
||||
);
|
||||
const selectedHostIdSet = useMemo(
|
||||
() => new Set(selectedHostIds),
|
||||
|
||||
@@ -132,6 +132,7 @@ export const SnippetsRightPanel: React.FC<SnippetsRightPanelProps> = ({
|
||||
customGroups={customGroups}
|
||||
selectedHostIds={targetSelection}
|
||||
multiSelect={true}
|
||||
allowLocalHosts={true}
|
||||
onSelect={handleTargetSelect}
|
||||
onSelectionChange={handleTargetSelectionChange}
|
||||
onBack={handleTargetPickerBack}
|
||||
|
||||
@@ -50,6 +50,7 @@ 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';
|
||||
@@ -182,7 +183,7 @@ function buildScriptSessionMeta(
|
||||
connected: session.status === 'connected',
|
||||
name: session.customName || session.hostLabel || host?.label,
|
||||
hostname: host?.hostname ?? session.hostname,
|
||||
username: host?.username ?? session.username,
|
||||
username: session.protocol === 'local' ? session.username : host?.username ?? session.username,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1195,14 +1196,16 @@ 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],
|
||||
[groupConfigs, hosts, proxyProfileIdSet, proxyProfiles, defaultLocalShell],
|
||||
);
|
||||
|
||||
// Pre-compute fallback hosts to avoid creating new objects on every render
|
||||
@@ -1215,11 +1218,12 @@ 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]);
|
||||
}, [sessions, hosts, groupConfigs, proxyProfiles, defaultLocalShell]);
|
||||
const resolvedSessionHostIds = useMemo(() => {
|
||||
const ids = new Set<string>();
|
||||
for (const session of sessions) {
|
||||
|
||||
@@ -5,6 +5,7 @@ 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');
|
||||
@@ -53,6 +54,36 @@ 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,12 +12,14 @@ 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';
|
||||
@@ -233,6 +235,7 @@ export function resolveTerminalPopupHost(
|
||||
groupConfigs?: GroupConfig[];
|
||||
proxyProfiles?: ProxyProfile[];
|
||||
localOs?: Host['os'];
|
||||
defaultLocalShell?: ResolveDefaultLocalShellContext;
|
||||
} = {},
|
||||
): Host {
|
||||
const resolvedHost = resolveTerminalSessionHost({
|
||||
@@ -241,6 +244,7 @@ export function resolveTerminalPopupHost(
|
||||
groupConfigs: options.groupConfigs ?? [],
|
||||
proxyProfiles: options.proxyProfiles ?? [],
|
||||
localOs: options.localOs ?? 'linux',
|
||||
defaultLocalShell: options.defaultLocalShell,
|
||||
});
|
||||
return applySourceSessionConnectionOverrides(
|
||||
resolvedHost,
|
||||
@@ -366,14 +370,17 @@ 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]);
|
||||
}, [config, groupConfigs, hosts, proxyProfiles, defaultLocalShell]);
|
||||
|
||||
const chainHosts = useMemo(() => resolveTerminalChainHosts({
|
||||
host,
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
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);
|
||||
});
|
||||
+103
-12
@@ -73,6 +73,7 @@ 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,
|
||||
@@ -238,7 +239,9 @@ interface VaultViewProps {
|
||||
onDeleteHost: (id: string) => void;
|
||||
onConnect: (host: Host) => void;
|
||||
onOpenHostFromNote?: (host: Host, source?: { noteId: string }) => void;
|
||||
onUpdateHosts: (hosts: Host[]) => VaultHostPersistenceResult | Promise<VaultHostPersistenceResult>;
|
||||
onUpdateHosts: (
|
||||
hosts: Host[] | ((prev: Host[]) => Host[]),
|
||||
) => VaultHostPersistenceResult | Promise<VaultHostPersistenceResult>;
|
||||
onReadPersistedHosts: () => Promise<Host[]>;
|
||||
onUpdateKeys: (keys: SSHKey[]) => void;
|
||||
onImportOrReuseKey: (draft: Partial<SSHKey>) => SSHKey;
|
||||
@@ -458,6 +461,9 @@ 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,
|
||||
);
|
||||
@@ -473,8 +479,17 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
|
||||
setCurrentSection("hosts");
|
||||
setSelectedGroupPath(null);
|
||||
setNewHostGroupPath(null);
|
||||
setEditingHost(deepLinkHostDraft);
|
||||
setIsHostPanelOpen(true);
|
||||
if (deepLinkHostDraft.protocol === "local") {
|
||||
setEditingHost(null);
|
||||
setIsHostPanelOpen(false);
|
||||
setEditingLocalShellHost(deepLinkHostDraft);
|
||||
setIsLocalShellHostPanelOpen(true);
|
||||
} else {
|
||||
setIsLocalShellHostPanelOpen(false);
|
||||
setEditingLocalShellHost(null);
|
||||
setEditingHost(deepLinkHostDraft);
|
||||
setIsHostPanelOpen(true);
|
||||
}
|
||||
onDeepLinkHostDraftHandled?.();
|
||||
}, [deepLinkHostDraft, onDeepLinkHostDraftHandled]);
|
||||
|
||||
@@ -507,17 +522,17 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
|
||||
useEffect(() => {
|
||||
const currentIds = new Set(hosts.map((h) => h.id));
|
||||
// Check against previous IDs before updating the ref
|
||||
if (
|
||||
editingHost &&
|
||||
knownHostIdsRef.current.has(editingHost.id) &&
|
||||
!currentIds.has(editingHost.id)
|
||||
) {
|
||||
const editingDeleted = (openHost: Host | null) =>
|
||||
Boolean(openHost && knownHostIdsRef.current.has(openHost.id) && !currentIds.has(openHost.id));
|
||||
if (editingDeleted(editingHost) || editingDeleted(editingLocalShellHost)) {
|
||||
setIsHostPanelOpen(false);
|
||||
setEditingHost(null);
|
||||
setIsLocalShellHostPanelOpen(false);
|
||||
setEditingLocalShellHost(null);
|
||||
setNewHostGroupPath(null);
|
||||
}
|
||||
knownHostIdsRef.current = currentIds;
|
||||
}, [hosts, editingHost]);
|
||||
}, [hosts, editingHost, editingLocalShellHost]);
|
||||
|
||||
// Group panel state
|
||||
const [isGroupPanelOpen, setIsGroupPanelOpen] = useState(false);
|
||||
@@ -599,8 +614,12 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
|
||||
{},
|
||||
{ validProxyProfileIds: proxyProfileIdSet },
|
||||
);
|
||||
// Only prompt when Telnet is available but isn't the host's default protocol.
|
||||
if (effective.telnetEnabled && effective.protocol !== "telnet") {
|
||||
// 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) {
|
||||
setProtocolSelectHost(effective);
|
||||
} else if (effective.protocol === "telnet") {
|
||||
// Telnet-as-default wins over a stray moshEnabled flag.
|
||||
@@ -654,13 +673,67 @@ 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);
|
||||
}, []);
|
||||
@@ -687,6 +760,15 @@ 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);
|
||||
},
|
||||
@@ -1276,6 +1358,7 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
|
||||
handleUnmanageGroup,
|
||||
managedGroupPaths,
|
||||
moveHostToGroup,
|
||||
moveHostsToGroup,
|
||||
setDragOverDropTarget,
|
||||
setGroupDragOverDropTarget,
|
||||
} = useVaultGroupDragHandlers({
|
||||
@@ -1355,7 +1438,9 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
|
||||
const isHostsSectionActive = currentSection === "hosts";
|
||||
const hasHostsSidePanel =
|
||||
isHostsSectionActive &&
|
||||
((isGroupPanelOpen && !!editingGroupPath) || isHostPanelOpen);
|
||||
((isGroupPanelOpen && !!editingGroupPath)
|
||||
|| isHostPanelOpen
|
||||
|| isLocalShellHostPanelOpen);
|
||||
// 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.
|
||||
@@ -1469,6 +1554,9 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
|
||||
handleHostConnect,
|
||||
handleImportFileSelected,
|
||||
handleNewHost,
|
||||
handleNewLocalShellHost,
|
||||
handleCancelLocalShellHost,
|
||||
handleSaveLocalShellHost,
|
||||
handleProtocolSelect,
|
||||
handleQuickConnect,
|
||||
handleQuickConnectSaveHost,
|
||||
@@ -1488,6 +1576,8 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
|
||||
isDeleteGroupOpen,
|
||||
isGroupPanelOpen,
|
||||
isHostPanelOpen,
|
||||
isLocalShellHostPanelOpen,
|
||||
editingLocalShellHost,
|
||||
isHostsSectionActive,
|
||||
isImportOpen,
|
||||
isMultiSelectMode,
|
||||
@@ -1513,6 +1603,7 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
|
||||
managedSources,
|
||||
moveGroup,
|
||||
moveHostToGroup,
|
||||
moveHostsToGroup,
|
||||
Network,
|
||||
newFolderName,
|
||||
newHostGroupPath,
|
||||
|
||||
@@ -20,8 +20,10 @@ 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\(\)/);
|
||||
assert.match(vaultViewLayoutSource, /aria-hidden=\{isHostPanelOpen \? true : undefined\}/);
|
||||
assert.match(vaultViewLayoutSource, /inert=\{isHostPanelOpen \? true : undefined\}/);
|
||||
// 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\}/);
|
||||
});
|
||||
|
||||
test("vault sidebar toggle keeps an accessible action label", () => {
|
||||
|
||||
@@ -585,3 +585,20 @@ test("Codex approval render plan preserves every approval for the same item", ()
|
||||
["approval-3"],
|
||||
);
|
||||
});
|
||||
|
||||
test('fork is offered only for safe completed replies and hidden while streaming', () => {
|
||||
const messages: ChatMessage[] = [
|
||||
{ id: 'u', role: 'user', content: 'hello', timestamp: 1 },
|
||||
{ id: 'done', role: 'assistant', content: 'completed', timestamp: 2, executionStatus: 'completed' },
|
||||
{ id: 'failed', role: 'assistant', content: 'failed', timestamp: 3, executionStatus: 'failed' },
|
||||
];
|
||||
const render = (isStreaming: boolean) => renderToStaticMarkup(
|
||||
<I18nProvider locale="en">
|
||||
<TooltipProvider>
|
||||
<ChatMessageList messages={messages} isStreaming={isStreaming} onForkFromMessage={() => {}} />
|
||||
</TooltipProvider>
|
||||
</I18nProvider>,
|
||||
);
|
||||
assert.equal((render(false).match(/Fork from here/g) ?? []).length, 1);
|
||||
assert.equal((render(true).match(/Fork from here/g) ?? []).length, 0);
|
||||
});
|
||||
@@ -6,10 +6,11 @@
|
||||
* No avatars. Thinking blocks are collapsible.
|
||||
*/
|
||||
|
||||
import { AlertCircle, BookOpen, FileText, RotateCcw, SquareTerminal, X, ZoomIn, ZoomOut } from 'lucide-react';
|
||||
import { AlertCircle, BookOpen, FileText, GitFork, RotateCcw, SquareTerminal, X, ZoomIn, ZoomOut } from 'lucide-react';
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useI18n } from '../../application/i18n/I18nProvider';
|
||||
import type { ChatMessage, ToolCall as AgentToolCall } from '../../infrastructure/ai/types';
|
||||
import type { AISessionContextCompaction, ChatMessage, ToolCall as AgentToolCall } from '../../infrastructure/ai/types';
|
||||
import { getForkableMessageIds } from '../../domain/aiSessionFork';
|
||||
import { Dialog, DialogContent, DialogTitle } from '../ui/dialog';
|
||||
import {
|
||||
Conversation,
|
||||
@@ -92,6 +93,10 @@ interface ChatMessageListProps {
|
||||
/** Active chat session ID — used to filter standalone MCP approval blocks */
|
||||
activeSessionId?: string | null;
|
||||
activeCompaction?: ActiveCompactionUi | null;
|
||||
/** Stored compaction summary for the viewed session — fork boundaries must not fall inside its coverage. */
|
||||
contextCompaction?: AISessionContextCompaction | null;
|
||||
/** Present when the panel supports forking a conversation from a completed assistant response. */
|
||||
onForkFromMessage?: (messageId: string) => void;
|
||||
notes?: VaultNote[];
|
||||
hosts?: Host[];
|
||||
snippets?: Snippet[];
|
||||
@@ -160,6 +165,8 @@ export function buildCodexApprovalRenderPlan(
|
||||
const MESSAGE_RENDER_BATCH = 50;
|
||||
const MESSAGE_RENDER_STEP = 50;
|
||||
|
||||
const EMPTY_FORKABLE_SET: ReadonlySet<string> = new Set();
|
||||
|
||||
export function pruneResolvedApprovals(
|
||||
previous: ReadonlyMap<string, boolean>,
|
||||
messages: readonly ChatMessage[],
|
||||
@@ -180,6 +187,8 @@ const ChatMessageList: React.FC<ChatMessageListProps> = ({
|
||||
isStreaming,
|
||||
activeSessionId,
|
||||
activeCompaction = null,
|
||||
contextCompaction,
|
||||
onForkFromMessage,
|
||||
notes = [],
|
||||
hosts = [],
|
||||
snippets = [],
|
||||
@@ -399,6 +408,14 @@ const ChatMessageList: React.FC<ChatMessageListProps> = ({
|
||||
[messages],
|
||||
);
|
||||
|
||||
// "Fork from here" targets: completed assistant responses whose whole
|
||||
// prefix is a safe fork boundary (fully resolved tool calls, stored
|
||||
// compaction summary still accurate). Hidden entirely while a turn streams.
|
||||
const forkableMessageIds = useMemo(() => {
|
||||
if (!onForkFromMessage || isStreaming) return EMPTY_FORKABLE_SET;
|
||||
return getForkableMessageIds({ messages, contextCompaction: contextCompaction ?? undefined });
|
||||
}, [onForkFromMessage, isStreaming, messages, contextCompaction]);
|
||||
|
||||
// While a jump target is active, re-resolve the tail against the current list
|
||||
// so streaming appends cannot slide the window past the selected message.
|
||||
const effectiveTailCount = activeJumpMessageId
|
||||
@@ -798,6 +815,22 @@ const ChatMessageList: React.FC<ChatMessageListProps> = ({
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Fork from here — copies the conversation up to this completed
|
||||
response into a new session. Only on safe boundaries; hidden
|
||||
while a turn streams (forkableMessageIds empty). */}
|
||||
{!isUser && onForkFromMessage && forkableMessageIds.has(message.id) && (
|
||||
<div className="flex -mt-0.5 opacity-0 group-hover:opacity-100 focus-within:opacity-100">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onForkFromMessage(message.id)}
|
||||
className="flex items-center gap-1 rounded-md px-1.5 py-0.5 text-[11px] text-muted-foreground/40 hover:text-foreground hover:bg-white/[0.05] transition-colors cursor-pointer"
|
||||
>
|
||||
<GitFork size={11} />
|
||||
<span>{t('ai.chat.forkFromHere')}</span>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</MessageContent>
|
||||
</Message>
|
||||
);
|
||||
@@ -1042,6 +1075,8 @@ function areMessagesEqual(prev: ChatMessageListProps, next: ChatMessageListProps
|
||||
if (prev.onOpenVaultHost !== next.onOpenVaultHost) return false;
|
||||
if (prev.onOpenVaultSnippet !== next.onOpenVaultSnippet) return false;
|
||||
if (prev.onOpenVaultSection !== next.onOpenVaultSection) return false;
|
||||
if (prev.onForkFromMessage !== next.onForkFromMessage) return false;
|
||||
if (prev.contextCompaction !== next.contextCompaction) return false;
|
||||
if (prev.messages.length !== next.messages.length) return false;
|
||||
if (prev.messages === next.messages) return true;
|
||||
|
||||
|
||||
@@ -200,6 +200,7 @@ 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 { isPluginHostProtocol } from '../../domain/pluginConnection';
|
||||
import { canOpenDualPaneSftp } from '../../domain/sftpDualPaneOpen';
|
||||
import { Host } from '../../types';
|
||||
import { DistroAvatar } from '../DistroAvatar';
|
||||
import { getQuickSwitcherRowStateClass, shouldUseQuickSwitcherPointerNavigation } from '../QuickSwitcher';
|
||||
@@ -95,9 +95,11 @@ const SftpHostPickerInner: React.FC<SftpHostPickerProps> = ({
|
||||
|
||||
const filteredHosts = useMemo(() => {
|
||||
return hosts.filter((h) => {
|
||||
// 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;
|
||||
// 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;
|
||||
// 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);
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
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,7 +3653,13 @@ test("restored local reconnect runs the host startup command while automatic ret
|
||||
assert.deepEqual(sessionWrites, []);
|
||||
});
|
||||
|
||||
test("local session start uses per-session directory before global default", async () => {
|
||||
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 () => {
|
||||
let capturedOptions: Record<string, unknown> | null = null;
|
||||
const terminalBackend = {
|
||||
backendAvailable: () => true,
|
||||
@@ -3685,16 +3691,17 @@ test("local session start uses per-session directory before global default", asy
|
||||
hostname: "local",
|
||||
username: "",
|
||||
protocol: "local",
|
||||
localStartDir: "/Users/alice/project",
|
||||
localStartDir: hostDirectory,
|
||||
},
|
||||
terminalSettings: { localStartDir: "/Users/alice/default" },
|
||||
terminalSettings: { localStartDir: globalDirectory },
|
||||
terminalBackend,
|
||||
});
|
||||
|
||||
await createTerminalSessionStarters(ctx as never).startLocal(createTermStub() as never);
|
||||
|
||||
assert.equal(capturedOptions?.cwd, "/Users/alice/project");
|
||||
assert.equal(capturedOptions?.cwd, expectedDirectory);
|
||||
});
|
||||
}
|
||||
|
||||
test("local session restores cwd before startup command after attaching", async () => {
|
||||
const sessionWrites: Array<{ id: string; data: string; automated?: boolean }> = [];
|
||||
|
||||
@@ -1835,8 +1835,14 @@ 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;
|
||||
const sessionShellArgs = ctx.host.localShellArgs?.length ? ctx.host.localShellArgs : undefined;
|
||||
const localStartDir = ctx.host.localStartDir || ctx.terminalSettings?.localStartDir;
|
||||
|
||||
const id = await ctx.terminalBackend.startLocalSession({
|
||||
@@ -1844,7 +1850,7 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
|
||||
cols: term.cols,
|
||||
rows: term.rows,
|
||||
shell: sessionShell || undefined,
|
||||
shellArgs: sessionShellArgs || undefined,
|
||||
shellArgs: sessionShellArgs,
|
||||
cwd: localStartDir,
|
||||
env: {
|
||||
TERM: ctx.terminalSettings?.terminalEmulationType ?? "xterm-256color",
|
||||
|
||||
@@ -592,6 +592,7 @@ const AIChatPanelsHostInner: React.FC<AIChatPanelsHostProps> = ({
|
||||
updateLastMessage={aiConfig.updateLastMessage}
|
||||
updateMessageById={aiConfig.updateMessageById}
|
||||
persistContextCompaction={aiConfig.persistContextCompaction}
|
||||
forkSessionFromMessage={aiConfig.forkSessionFromMessage}
|
||||
providers={aiConfig.providers}
|
||||
activeProviderId={aiConfig.activeProviderId}
|
||||
activeModelId={aiConfig.activeModelId}
|
||||
|
||||
@@ -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,6 +27,7 @@ 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>;
|
||||
|
||||
@@ -598,7 +599,7 @@ export function VaultHostListSection({ ctx }: { ctx: VaultHostListSectionContext
|
||||
<HostNotesIndicator notes={safeHost.notes} />
|
||||
</div>
|
||||
<div className="text-[11px] text-muted-foreground font-mono truncate leading-4">
|
||||
{safeHost.username}@{safeHost.hostname}
|
||||
{getVaultHostRowSubtitle(safeHost, { localFallback: t('localShell.subtitle') })}
|
||||
</div>
|
||||
</div>
|
||||
{viewMode === "grid" && renderHostEditButton(host)}
|
||||
@@ -718,7 +719,7 @@ export function VaultHostListSection({ ctx }: { ctx: VaultHostListSectionContext
|
||||
<HostNotesIndicator notes={safeHost.notes} />
|
||||
</div>
|
||||
<div className="text-[11px] text-muted-foreground font-mono truncate leading-4">
|
||||
{safeHost.username}@{safeHost.hostname}
|
||||
{getVaultHostRowSubtitle(safeHost, { localFallback: t('localShell.subtitle') })}
|
||||
</div>
|
||||
</div>
|
||||
{viewMode === "grid" && renderHostEditButton(host)}
|
||||
@@ -1097,7 +1098,7 @@ export function VaultHostListSection({ ctx }: { ctx: VaultHostListSectionContext
|
||||
<HostNotesIndicator notes={safeHost.notes} />
|
||||
</div>
|
||||
<div className="text-[11px] text-muted-foreground font-mono truncate leading-4">
|
||||
{safeHost.username}@{safeHost.hostname}
|
||||
{getVaultHostRowSubtitle(safeHost, { localFallback: t('localShell.subtitle') })}
|
||||
</div>
|
||||
</div>
|
||||
{viewMode === "grid" && renderHostEditButton(host)}
|
||||
@@ -1247,7 +1248,7 @@ export function VaultHostListSection({ ctx }: { ctx: VaultHostListSectionContext
|
||||
<HostNotesIndicator notes={safeHost.notes} />
|
||||
</div>
|
||||
<div className="text-[11px] text-muted-foreground font-mono truncate leading-4">
|
||||
{safeHost.username}@{safeHost.hostname}
|
||||
{getVaultHostRowSubtitle(safeHost, { localFallback: t('localShell.subtitle') })}
|
||||
</div>
|
||||
</div>
|
||||
{viewMode === "grid" && renderHostEditButton(host)}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
import { Combobox } from "../ui/combobox";
|
||||
import React, { useCallback } from "react";
|
||||
import { deleteVaultKey } from "../../application/defaultKeyPassphrases";
|
||||
import { VaultGroupDeletionConfirmationChangedError } from "../../application/state/useVaultGroupDeletion";
|
||||
@@ -12,6 +13,7 @@ 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,
|
||||
@@ -172,6 +174,7 @@ export function VaultViewLayout({ ctx }: { ctx: VaultViewLayoutContext }) {
|
||||
Edit2,
|
||||
editingGroupPath,
|
||||
editingHost,
|
||||
editingLocalShellHost,
|
||||
editingHostGroupDefaults,
|
||||
FileCode,
|
||||
FileSymlink,
|
||||
@@ -195,6 +198,9 @@ export function VaultViewLayout({ ctx }: { ctx: VaultViewLayoutContext }) {
|
||||
handleHostConnect,
|
||||
handleImportFileSelected,
|
||||
handleNewHost,
|
||||
handleNewLocalShellHost,
|
||||
handleCancelLocalShellHost,
|
||||
handleSaveLocalShellHost,
|
||||
handleProtocolSelect,
|
||||
handleQuickConnect,
|
||||
handleQuickConnectSaveHost,
|
||||
@@ -214,6 +220,7 @@ export function VaultViewLayout({ ctx }: { ctx: VaultViewLayoutContext }) {
|
||||
isDeleteGroupOpen,
|
||||
isGroupPanelOpen,
|
||||
isHostPanelOpen,
|
||||
isLocalShellHostPanelOpen,
|
||||
isHostsSectionActive,
|
||||
isImportOpen,
|
||||
isMultiSelectMode,
|
||||
@@ -237,6 +244,7 @@ export function VaultViewLayout({ ctx }: { ctx: VaultViewLayoutContext }) {
|
||||
managedSources,
|
||||
moveGroup,
|
||||
moveHostToGroup,
|
||||
moveHostsToGroup,
|
||||
Network,
|
||||
newFolderName,
|
||||
newHostGroupPath,
|
||||
@@ -380,6 +388,9 @@ 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
|
||||
@@ -436,6 +447,8 @@ export function VaultViewLayout({ ctx }: { ctx: VaultViewLayoutContext }) {
|
||||
persistWidthStorageKey: STORAGE_KEY_VAULT_HOST_PANEL_WIDTH,
|
||||
resizeAriaLabel: t("vault.panel.resizeWidth"),
|
||||
};
|
||||
const [isMoveHostsOpen, setIsMoveHostsOpen] = React.useState(false);
|
||||
const [moveTargetGroup, setMoveTargetGroup] = React.useState("");
|
||||
const [isSidebarResizing, setIsSidebarResizing] = React.useState(false);
|
||||
const keyListRef = React.useRef(keys);
|
||||
keyListRef.current = keys;
|
||||
@@ -521,7 +534,7 @@ export function VaultViewLayout({ ctx }: { ctx: VaultViewLayoutContext }) {
|
||||
);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!isHostPanelOpen) return;
|
||||
if (!isHostAsideOpen) return;
|
||||
const activeElement = document.activeElement;
|
||||
if (!(activeElement instanceof HTMLElement)) return;
|
||||
if (
|
||||
@@ -530,7 +543,7 @@ export function VaultViewLayout({ ctx }: { ctx: VaultViewLayoutContext }) {
|
||||
) {
|
||||
activeElement.blur();
|
||||
}
|
||||
}, [isHostPanelOpen]);
|
||||
}, [isHostAsideOpen]);
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -961,12 +974,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",
|
||||
isHostPanelOpen
|
||||
isHostAsideOpen
|
||||
? "max-w-0 opacity-0 -ml-2 pointer-events-none"
|
||||
: "max-w-[260px] opacity-100",
|
||||
)}
|
||||
aria-hidden={isHostPanelOpen ? true : undefined}
|
||||
inert={isHostPanelOpen ? true : undefined}
|
||||
aria-hidden={isHostAsideOpen ? true : undefined}
|
||||
inert={isHostAsideOpen ? true : undefined}
|
||||
>
|
||||
<Dropdown>
|
||||
<div className="flex items-center rounded-md bg-primary text-primary-foreground">
|
||||
@@ -974,7 +987,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={isHostPanelOpen ? -1 : 0}
|
||||
tabIndex={isHostAsideOpen ? -1 : 0}
|
||||
>
|
||||
<Plus size={14} className="mr-2" />{" "}
|
||||
{t("vault.hosts.newHost")}
|
||||
@@ -983,13 +996,20 @@ 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={isHostPanelOpen ? -1 : 0}
|
||||
tabIndex={isHostAsideOpen ? -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"
|
||||
@@ -1029,19 +1049,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",
|
||||
isHostPanelOpen
|
||||
isHostAsideOpen
|
||||
? "max-w-0 opacity-0 -ml-3 pointer-events-none"
|
||||
: "max-w-[320px] opacity-100",
|
||||
)}
|
||||
aria-hidden={isHostPanelOpen ? true : undefined}
|
||||
inert={isHostPanelOpen ? true : undefined}
|
||||
aria-hidden={isHostAsideOpen ? true : undefined}
|
||||
inert={isHostAsideOpen ? true : undefined}
|
||||
>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
className={vaultHeaderSecondaryButtonClass}
|
||||
onClick={onCreateLocalTerminal}
|
||||
tabIndex={isHostPanelOpen ? -1 : 0}
|
||||
tabIndex={isHostAsideOpen ? -1 : 0}
|
||||
>
|
||||
<TerminalSquare size={14} className="mr-2" />{" "}
|
||||
{t("common.terminal")}
|
||||
@@ -1051,7 +1071,7 @@ export function VaultViewLayout({ ctx }: { ctx: VaultViewLayoutContext }) {
|
||||
variant="secondary"
|
||||
className={vaultHeaderSecondaryButtonClass}
|
||||
onClick={() => setIsSerialModalOpen(true)}
|
||||
tabIndex={isHostPanelOpen ? -1 : 0}
|
||||
tabIndex={isHostAsideOpen ? -1 : 0}
|
||||
>
|
||||
<Usb size={14} className="mr-2" /> {t("serial.button")}
|
||||
</Button>
|
||||
@@ -1114,6 +1134,19 @@ export function VaultViewLayout({ ctx }: { ctx: VaultViewLayoutContext }) {
|
||||
count: selectedHostIds.size,
|
||||
})}
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
className="h-7 px-2 text-xs"
|
||||
disabled={selectedHostIds.size === 0}
|
||||
onClick={() => {
|
||||
setMoveTargetGroup("");
|
||||
setIsMoveHostsOpen(true);
|
||||
}}
|
||||
>
|
||||
<FolderTree size={12} className="mr-1" />
|
||||
{t("vault.hosts.moveSelected", { count: selectedHostIds.size })}
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
@@ -1527,6 +1560,26 @@ 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>
|
||||
|
||||
@@ -1758,6 +1811,43 @@ export function VaultViewLayout({ ctx }: { ctx: VaultViewLayoutContext }) {
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<Dialog open={isMoveHostsOpen} onOpenChange={setIsMoveHostsOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t("vault.hosts.moveSelected", { count: selectedHostIds.size })}</DialogTitle>
|
||||
<DialogDescription>{t("vault.hosts.moveSelected.description")}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<Combobox
|
||||
options={[
|
||||
{ value: "", label: t("vault.hosts.allHosts") },
|
||||
...allGroupPaths
|
||||
.filter((path: string) => path !== "General"
|
||||
|| customGroups.includes("General")
|
||||
|| allGroupPaths.some((group: string) => group.startsWith("General/")))
|
||||
.map((path: string) => ({ value: path, label: path })),
|
||||
]}
|
||||
value={moveTargetGroup}
|
||||
onValueChange={setMoveTargetGroup}
|
||||
ariaLabel={t("hostDetails.group.placeholder")}
|
||||
placeholder={t("vault.hosts.allHosts")}
|
||||
clearable={false}
|
||||
/>
|
||||
<DialogFooter>
|
||||
<Button variant="ghost" onClick={() => setIsMoveHostsOpen(false)}>{t("common.cancel")}</Button>
|
||||
<Button
|
||||
disabled={selectedHostIds.size === 0}
|
||||
onClick={() => {
|
||||
moveHostsToGroup(selectedHostIds, moveTargetGroup || null);
|
||||
setIsMoveHostsOpen(false);
|
||||
clearHostSelection();
|
||||
}}
|
||||
>
|
||||
{t("vault.hosts.moveSelected", { count: selectedHostIds.size })}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<ImportVaultDialog
|
||||
open={isImportOpen}
|
||||
onOpenChange={setIsImportOpen}
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import React from 'react';
|
||||
import { act, create, type ReactTestRenderer } from 'react-test-renderer';
|
||||
import type { Host, ManagedSource } from '../../types';
|
||||
import { useVaultGroupDragHandlers } from './useVaultGroupDragHandlers';
|
||||
|
||||
const host = (id: string, extra: Partial<Host> = {}): Host => ({
|
||||
id, label: `Host ${id}`, hostname: `${id}.example`, username: 'user', port: 22,
|
||||
tags: ['keep'], group: 'Old', ...extra,
|
||||
});
|
||||
|
||||
test('bulk group move publishes all selected hosts together and preserves unselected hosts', async () => {
|
||||
const original = [host('a'), host('b', { protocol: 'telnet' }), host('c')];
|
||||
const writes: Host[][] = [];
|
||||
await withActions(original, [], actions => {
|
||||
act(() => actions.moveHostsToGroup(new Set(['a', 'b']), 'New/Nested'));
|
||||
assert.equal(writes.length, 1);
|
||||
assert.deepEqual(writes[0].map(h => h.group), ['New/Nested', 'New/Nested', 'Old']);
|
||||
assert.equal(writes[0][2], original[2]);
|
||||
assert.deepEqual(writes[0][0].tags, ['keep']);
|
||||
assert.equal(writes[0][0].hostname, original[0].hostname);
|
||||
}, writes);
|
||||
});
|
||||
|
||||
test('mixed protocols entering and leaving managed groups retain single-host move rules', async () => {
|
||||
const original = [host('ssh'), host('telnet', { protocol: 'telnet' }), host('local', { protocol: 'local' })];
|
||||
const sources = [{ id: 'parent', groupName: 'Managed' }, { id: 'child', groupName: 'Managed/Nested' }] as ManagedSource[];
|
||||
const writes: Host[][] = [];
|
||||
await withActions(original, sources, actions => {
|
||||
act(() => actions.moveHostsToGroup(new Set(original.map(h => h.id)), 'Managed/Nested'));
|
||||
assert.equal(writes.length, 1);
|
||||
assert.equal(writes[0][0].managedSourceId, 'child');
|
||||
assert.equal(writes[0][0].label, 'Hostssh');
|
||||
assert.equal(writes[0][1].managedSourceId, undefined);
|
||||
assert.equal(writes[0][1].label, 'Host telnet');
|
||||
assert.equal(writes[0][2].managedSourceId, undefined);
|
||||
assert.equal(writes[0][2].label, 'Host local');
|
||||
}, writes);
|
||||
await withActions(writes[0], sources, actions => {
|
||||
act(() => actions.moveHostsToGroup(new Set(original.map(h => h.id)), null));
|
||||
assert.deepEqual(writes[1].map(h => h.group), ['', '', '']);
|
||||
assert.ok(writes[1].every(h => h.managedSourceId === undefined));
|
||||
}, writes);
|
||||
});
|
||||
|
||||
test('same-group, missing and empty selections do not publish changes', async () => {
|
||||
const writes: Host[][] = [];
|
||||
await withActions([host('a')], [], actions => {
|
||||
act(() => actions.moveHostsToGroup(new Set(['a']), 'Old'));
|
||||
act(() => actions.moveHostsToGroup(new Set(['missing']), 'New'));
|
||||
act(() => actions.moveHostsToGroup(new Set(), 'New'));
|
||||
assert.equal(writes.length, 0);
|
||||
act(() => actions.moveHostToGroup('a', 'New'));
|
||||
assert.equal(writes.length, 1);
|
||||
assert.equal(writes[0][0].group, 'New');
|
||||
}, writes);
|
||||
});
|
||||
|
||||
async function withActions(
|
||||
hosts: Host[], managedSources: ManagedSource[],
|
||||
run: (actions: ReturnType<typeof useVaultGroupDragHandlers>) => void,
|
||||
writes: Host[][],
|
||||
) {
|
||||
const previousWindow = globalThis.window;
|
||||
const actEnvironment = globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean };
|
||||
const previousAct = actEnvironment.IS_REACT_ACT_ENVIRONMENT;
|
||||
actEnvironment.IS_REACT_ACT_ENVIRONMENT = true;
|
||||
globalThis.window = { setTimeout: () => 1, clearTimeout: () => {} } as unknown as Window & typeof globalThis;
|
||||
let renderer: ReactTestRenderer | undefined;
|
||||
let actions: ReturnType<typeof useVaultGroupDragHandlers>;
|
||||
function Probe() {
|
||||
actions = useVaultGroupDragHandlers({ hosts, managedSources,
|
||||
onUpdateHosts: next => writes.push(next), onUpdateManagedSources: () => {}, t: key => key });
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
await act(async () => { renderer = create(<Probe />); });
|
||||
run(actions!);
|
||||
} finally {
|
||||
await act(async () => renderer?.unmount());
|
||||
globalThis.window = previousWindow;
|
||||
actEnvironment.IS_REACT_ACT_ENVIRONMENT = previousAct;
|
||||
}
|
||||
}
|
||||
@@ -66,53 +66,40 @@ export function useVaultGroupDragHandlers({
|
||||
setDragOverDropTarget(path ? { kind: "group", path } : null);
|
||||
}, []);
|
||||
|
||||
const moveHostToGroup = useCallback((hostId: string, groupPath: string | null) => {
|
||||
const moveHostsToGroup = useCallback((hostIds: ReadonlySet<string>, groupPath: string | null) => {
|
||||
const targetGroup = groupPath || "";
|
||||
const hostToMove = hosts.find((h) => h.id === hostId);
|
||||
if (!hostToMove || (hostToMove.group || "") === targetGroup) {
|
||||
setDragOverDropTarget(null);
|
||||
return;
|
||||
}
|
||||
|
||||
// Find the most specific (deepest) managed source that matches the target group
|
||||
const targetManagedSource = managedSources
|
||||
.filter(s => targetGroup === s.groupName || targetGroup.startsWith(s.groupName + "/"))
|
||||
.sort((a, b) => b.groupName.length - a.groupName.length)[0];
|
||||
|
||||
const movedHost = {
|
||||
...hostToMove,
|
||||
group: targetGroup,
|
||||
};
|
||||
const updatedHost = (() => {
|
||||
const h = movedHost;
|
||||
|
||||
// Only SSH hosts can be managed (SSH config only supports SSH)
|
||||
const canBeManaged = !h.protocol || h.protocol === "ssh";
|
||||
|
||||
// Sanitize label if moving to a managed group (SSH config requires no spaces in Host alias)
|
||||
let label = h.label;
|
||||
if (targetManagedSource && canBeManaged && label) {
|
||||
label = label.replace(/\s/g, '');
|
||||
}
|
||||
|
||||
return {
|
||||
...h,
|
||||
label,
|
||||
group: targetGroup,
|
||||
managedSourceId: (targetManagedSource && canBeManaged) ? targetManagedSource.id : undefined,
|
||||
};
|
||||
})();
|
||||
onUpdateHosts(hosts.map((host) => (host.id === hostId ? updatedHost : host)));
|
||||
let movedCount = 0;
|
||||
const updatedHosts = hosts.map(host => {
|
||||
if (!hostIds.has(host.id) || (host.group || "") === targetGroup) return host;
|
||||
movedCount += 1;
|
||||
const canBeManaged = !host.protocol || host.protocol === "ssh";
|
||||
return {
|
||||
...host,
|
||||
group: targetGroup,
|
||||
label: targetManagedSource && canBeManaged
|
||||
? host.label.replace(/\s/g, '') : host.label,
|
||||
managedSourceId: targetManagedSource && canBeManaged ? targetManagedSource.id : undefined,
|
||||
};
|
||||
});
|
||||
setDragOverDropTarget(null);
|
||||
if (movedCount === 0) return;
|
||||
// Publish the entire selection once; repeated single-host updates would
|
||||
// each start from the same render snapshot and overwrite earlier moves.
|
||||
onUpdateHosts(updatedHosts);
|
||||
pulseDropTarget(groupPath ? { kind: "group", path: groupPath } : { kind: "root" });
|
||||
toast.success(
|
||||
t("vault.hosts.moveToGroup.success", {
|
||||
host: hostToMove.label,
|
||||
group: groupPath || t("vault.hosts.allHosts"),
|
||||
}),
|
||||
);
|
||||
toast.success(t("vault.hosts.moveMultiple.success", {
|
||||
count: movedCount,
|
||||
group: groupPath || t("vault.hosts.allHosts"),
|
||||
}));
|
||||
}, [hosts, managedSources, onUpdateHosts, pulseDropTarget, t]);
|
||||
|
||||
|
||||
const moveHostToGroup = useCallback((hostId: string, groupPath: string | null) => {
|
||||
moveHostsToGroup(new Set([hostId]), groupPath);
|
||||
}, [moveHostsToGroup]);
|
||||
|
||||
const getDropTargetClasses = (target: DropTarget) =>
|
||||
cn(
|
||||
isSameDropTarget(dragOverDropTarget, target) &&
|
||||
@@ -151,6 +138,7 @@ export function useVaultGroupDragHandlers({
|
||||
handleUnmanageGroup,
|
||||
managedGroupPaths,
|
||||
moveHostToGroup,
|
||||
moveHostsToGroup,
|
||||
setDragOverDropTarget,
|
||||
setGroupDragOverDropTarget,
|
||||
};
|
||||
|
||||
@@ -0,0 +1,318 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import {
|
||||
buildForkTitle,
|
||||
canForkFromMessage,
|
||||
getForkableMessageIds,
|
||||
planSessionFork,
|
||||
stripMessageContinuationState,
|
||||
} from './aiSessionFork.ts';
|
||||
|
||||
import type { ForkMessage } from './aiSessionFork.ts';
|
||||
|
||||
let nextId = 0;
|
||||
const msg = (overrides: Partial<ForkMessage>): ForkMessage => ({
|
||||
id: `m${nextId++}`,
|
||||
role: 'assistant',
|
||||
content: '',
|
||||
timestamp: 0,
|
||||
...overrides,
|
||||
} as ForkMessage);
|
||||
|
||||
const session = (messages: ForkMessage[], contextCompaction?: { summary: string; compactedMessageCount: number }) => ({
|
||||
title: 'Debug deploy',
|
||||
messages,
|
||||
contextCompaction,
|
||||
});
|
||||
|
||||
test('planSessionFork keeps the original untouched and plans a copy through the boundary', () => {
|
||||
const user = msg({ role: 'user', content: 'run tests' });
|
||||
const assistant = msg({ content: 'done, all green' });
|
||||
const source = session([user, assistant]);
|
||||
const plan = planSessionFork(source, assistant.id);
|
||||
assert.equal(plan.ok, true);
|
||||
if (!plan.ok) return;
|
||||
assert.equal(plan.boundaryIndex, 1);
|
||||
assert.deepEqual(plan.messages, [user, assistant]);
|
||||
assert.equal(plan.title, 'Debug deploy (fork)');
|
||||
});
|
||||
|
||||
test('planSessionFork rejects non-assistant and missing boundaries', () => {
|
||||
const source = session([
|
||||
msg({ role: 'user', content: 'hi' }),
|
||||
]);
|
||||
assert.deepEqual(planSessionFork(source, source.messages[0].id), { ok: false, reason: 'not-assistant' });
|
||||
assert.deepEqual(planSessionFork(source, 'missing'), { ok: false, reason: 'message-not-found' });
|
||||
});
|
||||
|
||||
test('planSessionFork rejects failed, cancelled, running, and streaming boundaries', () => {
|
||||
for (const executionStatus of ['failed', 'cancelled', 'running', 'pending']) {
|
||||
const source = session([msg({ content: 'x', executionStatus })]);
|
||||
assert.deepEqual(planSessionFork(source, source.messages[0].id), {
|
||||
ok: false,
|
||||
reason: 'not-completed',
|
||||
});
|
||||
}
|
||||
const streamingSource = session([msg({ statusText: 'Waiting for response…' })]);
|
||||
assert.deepEqual(planSessionFork(streamingSource, streamingSource.messages[0].id), {
|
||||
ok: false,
|
||||
reason: 'not-completed',
|
||||
});
|
||||
const failedSource = session([msg({ errorInfo: { type: 'provider', message: 'boom', retryable: true } })]);
|
||||
assert.deepEqual(planSessionFork(failedSource, failedSource.messages[0].id), {
|
||||
ok: false,
|
||||
reason: 'not-completed',
|
||||
});
|
||||
const pendingSource = session([msg({ pendingApproval: {} })]);
|
||||
assert.deepEqual(planSessionFork(pendingSource, pendingSource.messages[0].id), {
|
||||
ok: false,
|
||||
reason: 'not-completed',
|
||||
});
|
||||
});
|
||||
|
||||
test('planSessionFork rejects boundaries with dangling tool calls', () => {
|
||||
const source = session([
|
||||
msg({ role: 'user', content: 'hi' }),
|
||||
msg({ content: '', toolCalls: [{ id: 'call-1' }] }),
|
||||
msg({ content: '', toolCalls: [{ id: 'call-2' }] }),
|
||||
msg({
|
||||
role: 'tool',
|
||||
content: 'ok',
|
||||
toolResults: [{ toolCallId: 'call-1', content: 'out' }],
|
||||
}),
|
||||
msg({ content: 'final answer' }),
|
||||
]);
|
||||
// call-2 never got a result inside the retained prefix
|
||||
assert.deepEqual(planSessionFork(source, source.messages[4].id), {
|
||||
ok: false,
|
||||
reason: 'dangling-tool-call',
|
||||
});
|
||||
// forking at the tool-issuing assistant turns is not possible anyway
|
||||
assert.deepEqual(planSessionFork(source, source.messages[2].id), {
|
||||
ok: false,
|
||||
reason: 'dangling-tool-call',
|
||||
});
|
||||
});
|
||||
|
||||
test('planSessionFork forks at the first turn boundary after results resolve', () => {
|
||||
const source = session([
|
||||
msg({ role: 'user', content: 'hi' }),
|
||||
msg({ role: 'assistant', content: '', toolCalls: [{ id: 'call-1' }] }),
|
||||
msg({ role: 'tool', content: 'ok', toolResults: [{ toolCallId: 'call-1', content: 'out' }] }),
|
||||
msg({ role: 'assistant', content: 'all done' }),
|
||||
]);
|
||||
assert.deepEqual(planSessionFork(source, source.messages[1].id), {
|
||||
ok: false,
|
||||
reason: 'dangling-tool-call',
|
||||
});
|
||||
const plan = planSessionFork(source, source.messages[3].id);
|
||||
assert.equal(plan.ok, true);
|
||||
});
|
||||
|
||||
test('planSessionFork rejects boundaries with dangling tool results', () => {
|
||||
const source = session([
|
||||
msg({ role: 'assistant', content: 'x' }),
|
||||
msg({ role: 'tool', content: 'ok', toolResults: [{ toolCallId: 'orphan', content: 'out' }] }),
|
||||
msg({ role: 'assistant', content: 'answer' }),
|
||||
]);
|
||||
assert.deepEqual(planSessionFork(source, source.messages[2].id), {
|
||||
ok: false,
|
||||
reason: 'dangling-tool-result',
|
||||
});
|
||||
});
|
||||
|
||||
test('planSessionFork lets the compaction summary ride along while it stays accurate', () => {
|
||||
const source = session(
|
||||
[msg({ role: 'user', content: 'hi' }), msg({ content: 'done' })],
|
||||
{ summary: 'earlier talk', compactedMessageCount: 1 },
|
||||
);
|
||||
const plan = planSessionFork(source, source.messages[1].id);
|
||||
assert.equal(plan.ok, true);
|
||||
if (plan.ok) {
|
||||
assert.deepEqual(plan.contextCompaction, { summary: 'earlier talk', compactedMessageCount: 1 });
|
||||
}
|
||||
});
|
||||
|
||||
test('planSessionFork refuses when the compaction summary would cover discarded messages', () => {
|
||||
const early = msg({ content: 'early turn' });
|
||||
const source = session(
|
||||
[early, msg({ content: 'later turn' })],
|
||||
{ summary: 'covers both turns', compactedMessageCount: 2 },
|
||||
);
|
||||
assert.deepEqual(planSessionFork(source, early.id), {
|
||||
ok: false,
|
||||
reason: 'compaction-covers-discarded',
|
||||
});
|
||||
});
|
||||
|
||||
test('planSessionFork preserves provider replay metadata but strips turn state', () => {
|
||||
const continuation = { reasoningParts: [{ text: 'chain of thought' }] };
|
||||
const user = msg({ role: 'user', content: 'hi', providerContinuation: continuation, statusText: 'streaming…' });
|
||||
const assistant = msg({ content: 'answer', providerContinuation: continuation });
|
||||
const source = session([user, assistant]);
|
||||
const plan = planSessionFork(source, assistant.id);
|
||||
assert.equal(plan.ok, true);
|
||||
if (plan.ok) {
|
||||
for (const message of plan.messages) {
|
||||
assert.equal(message.providerContinuation, continuation);
|
||||
assert.equal(message.statusText, undefined);
|
||||
}
|
||||
}
|
||||
// untouched source keeps its original message objects and turn state
|
||||
assert.equal(source.messages[0].providerContinuation, continuation);
|
||||
assert.equal(source.messages[0].statusText, 'streaming…');
|
||||
});
|
||||
|
||||
test('planSessionFork pairs repeated tool-call ids by occurrence', () => {
|
||||
const source = session([
|
||||
msg({ role: 'user', content: 'hi' }),
|
||||
msg({ role: 'assistant', content: '', toolCalls: [{ id: 'call-1' }] }),
|
||||
msg({ role: 'tool', content: 'first', toolResults: [{ toolCallId: 'call-1', content: 'out 1' }] }),
|
||||
msg({ role: 'assistant', content: '', toolCalls: [{ id: 'call-1' }] }),
|
||||
msg({ role: 'assistant', content: 'answer' }),
|
||||
]);
|
||||
// The provider reused call-1; the second occurrence never got a result.
|
||||
assert.deepEqual(planSessionFork(source, source.messages[4].id), {
|
||||
ok: false,
|
||||
reason: 'dangling-tool-call',
|
||||
});
|
||||
const resolved = session([
|
||||
msg({ role: 'assistant', content: '', toolCalls: [{ id: 'call-1' }] }),
|
||||
msg({ role: 'tool', content: 'first', toolResults: [{ toolCallId: 'call-1', content: 'out 1' }] }),
|
||||
msg({ role: 'assistant', content: '', toolCalls: [{ id: 'call-1' }] }),
|
||||
msg({ role: 'tool', content: 'second', toolResults: [{ toolCallId: 'call-1', content: 'out 2' }] }),
|
||||
msg({ role: 'assistant', content: 'answer' }),
|
||||
]);
|
||||
assert.equal(planSessionFork(resolved, resolved.messages[4].id).ok, true);
|
||||
// One result cannot satisfy two pending calls with the same id.
|
||||
const underResolved = session([
|
||||
msg({ role: 'assistant', content: '', toolCalls: [{ id: 'call-1' }, { id: 'call-1' }] }),
|
||||
msg({ role: 'tool', content: 'single', toolResults: [{ toolCallId: 'call-1', content: 'out' }] }),
|
||||
msg({ role: 'assistant', content: 'answer' }),
|
||||
]);
|
||||
assert.deepEqual(planSessionFork(underResolved, underResolved.messages[2].id), {
|
||||
ok: false,
|
||||
reason: 'dangling-tool-call',
|
||||
});
|
||||
});
|
||||
|
||||
test('stripMessageContinuationState preserves values and clears transient state', () => {
|
||||
const plain = msg({ content: 'x' });
|
||||
assert.deepEqual(stripMessageContinuationState(plain), plain);
|
||||
const withStatusText = msg({ content: 'x', statusText: 'streaming…' });
|
||||
assert.notEqual(stripMessageContinuationState(withStatusText), withStatusText);
|
||||
const withPendingApproval = msg({ content: 'x', pendingApproval: {} });
|
||||
assert.notEqual(stripMessageContinuationState(withPendingApproval), withPendingApproval);
|
||||
const continuationOnly = msg({ content: 'x', providerContinuation: {} });
|
||||
assert.deepEqual(stripMessageContinuationState(continuationOnly), continuationOnly);
|
||||
});
|
||||
|
||||
test('buildForkTitle appends a fork marker without double-suffixing', () => {
|
||||
assert.equal(buildForkTitle('Debug deploy'), 'Debug deploy (fork)');
|
||||
assert.equal(buildForkTitle('Debug deploy (fork)'), 'Debug deploy (fork)');
|
||||
assert.equal(buildForkTitle(''), 'New Chat (fork)');
|
||||
});
|
||||
|
||||
test('canForkFromMessage mirrors planSessionFork success', () => {
|
||||
const source = session([msg({ role: 'user', content: 'hi' }), msg({ content: 'done' })]);
|
||||
assert.equal(canForkFromMessage(source, source.messages[1].id), true);
|
||||
assert.equal(canForkFromMessage(source, source.messages[0].id), false);
|
||||
assert.equal(canForkFromMessage(source, 'nope'), false);
|
||||
});
|
||||
|
||||
test('fork keeps output previews but removes source-only saved-output references', () => {
|
||||
const assistant = msg({ content: 'preview handleId=tool-output-abc', toolResults: [{ toolCallId: 'call', content: '{"handleId":"tool-output-xyz","preview":"retained"}' }] });
|
||||
const source = session([msg({ toolCalls: [{ id: 'call' }] }), assistant], { summary: 'archive handleId=tool-output-summary', compactedMessageCount: 1 });
|
||||
const plan = planSessionFork(source, assistant.id);
|
||||
assert.ok(plan.ok);
|
||||
assert.doesNotMatch(JSON.stringify(plan), /tool-output-/);
|
||||
assert.match(plan.messages[1].content, /preview/);
|
||||
assert.match(plan.messages[1].toolResults![0].content!, /retained/);
|
||||
assert.equal(JSON.parse(plan.messages[1].toolResults![0].content!).handleId, null);
|
||||
assert.match(source.messages[1].content, /tool-output-abc/);
|
||||
});
|
||||
|
||||
test('fork removes source output handles only from tool_output_read arguments', () => {
|
||||
const read = msg({ toolCalls: [{ id: 'read', name: 'tool_output_read', arguments: { handleId: 'tool-output-source', offset: 12 } }] });
|
||||
const done = msg({ toolResults: [{ toolCallId: 'read', content: 'preview' }], content: 'done' });
|
||||
const source = session([read, done]);
|
||||
const plan = planSessionFork(source, done.id);
|
||||
assert.ok(plan.ok);
|
||||
assert.equal(plan.messages[0].toolCalls![0].arguments!.handleId, undefined);
|
||||
assert.equal(plan.messages[0].toolCalls![0].arguments!.offset, 12);
|
||||
assert.equal(read.toolCalls![0].arguments!.handleId, 'tool-output-source');
|
||||
|
||||
const other = msg({ toolCalls: [{ id: 'other', name: 'custom_tool', arguments: { handleId: 'tool-output-not-owned-here' } }] });
|
||||
assert.deepEqual(stripMessageContinuationState(other).toolCalls, other.toolCalls);
|
||||
});
|
||||
|
||||
test('fork refuses source output references in provider reasoning without changing signed metadata', () => {
|
||||
const continuation = { reasoningParts: [{ text: 'Read handleId=tool-output-source for details', providerOptions: { anthropic: { signature: 'valid-signature' } } }] };
|
||||
const source = session([msg({ content: 'done', providerContinuation: continuation })]);
|
||||
assert.deepEqual(planSessionFork(source, source.messages[0].id), { ok: false, reason: 'source-output-reasoning' });
|
||||
assert.equal(canForkFromMessage(source, source.messages[0].id), false);
|
||||
assert.equal(source.messages[0].providerContinuation, continuation);
|
||||
assert.equal(continuation.reasoningParts[0].text, 'Read handleId=tool-output-source for details');
|
||||
});
|
||||
|
||||
test('fork eligibility does not clone or scan retained message bodies', () => {
|
||||
const assistant = msg({ content: 'completed' });
|
||||
Object.defineProperty(assistant, 'content', { get() { throw new Error('eligibility should not build a transcript'); } });
|
||||
assert.equal(canForkFromMessage(session([assistant]), assistant.id), true);
|
||||
});
|
||||
|
||||
test('one-pass fork eligibility matches clicked-boundary validation', () => {
|
||||
const messages = [
|
||||
msg({ role: 'user', content: 'start' }),
|
||||
msg({ content: 'first completed reply' }),
|
||||
msg({ toolCalls: [{ id: 'reused' }, { id: 'reused' }] }),
|
||||
msg({ role: 'tool', toolResults: [{ toolCallId: 'reused', content: 'one' }] }),
|
||||
msg({ content: 'still unresolved' }),
|
||||
msg({ role: 'tool', toolResults: [{ toolCallId: 'reused', content: 'two' }] }),
|
||||
msg({ content: 'complete exchange' }),
|
||||
msg({ executionStatus: 'failed' }),
|
||||
msg({ content: 'unsafe reasoning', providerContinuation: { reasoningParts: [{ text: 'Read tool-output-source' }] } }),
|
||||
msg({ content: 'after unsafe reasoning' }),
|
||||
];
|
||||
for (const compactedMessageCount of [0, 3, messages.length]) {
|
||||
const source = session(messages, { summary: 'summary', compactedMessageCount });
|
||||
assert.deepEqual([...getForkableMessageIds(source)], messages.filter(message => planSessionFork(source, message.id).ok).map(message => message.id));
|
||||
}
|
||||
const orphan = session([msg({ role: 'tool', toolResults: [{ toolCallId: 'orphan' }] }), msg({ content: 'reply' })]);
|
||||
assert.equal(getForkableMessageIds(orphan).size, 0);
|
||||
});
|
||||
|
||||
test('fork removes bare output identifiers in transcript previews while preserving JSON', () => {
|
||||
const assistant = msg({ content: 'Read tool-output-source with tool_output_read', thinking: 'Output tool-output-source is saved', toolResults: [{ toolCallId: 'call', content: '{"handleId":"tool-output-source","preview":"Read tool-output-source"}' }] });
|
||||
const plan = planSessionFork(session([msg({ toolCalls: [{ id: 'call' }] }), assistant]), assistant.id);
|
||||
assert.ok(plan.ok);
|
||||
assert.doesNotMatch(JSON.stringify(plan.messages), /tool-output-source/);
|
||||
const parsed = JSON.parse(plan.messages[1].toolResults![0].content!);
|
||||
assert.equal(parsed.handleId, null);
|
||||
assert.match(parsed.preview, /Read/);
|
||||
assert.match(assistant.content, /tool-output-source/);
|
||||
});
|
||||
|
||||
test('fork sanitizes attachment previews without changing attachment data or identity', () => {
|
||||
const attachment = { base64Data: 'SEVMTE8=', mediaType: 'text/plain', filename: 'terminal-selection.log', terminalSelection: true, lineCount: 3, filePath: '/original/selection.log', previewText: 'Read tool-output-selection with tool_output_read' };
|
||||
const image = { base64Data: 'UE5H', mediaType: 'image/png', filename: 'original.png' };
|
||||
const user = msg({ role: 'user', content: 'Inspect', attachments: [attachment, image], images: [attachment] });
|
||||
const assistant = msg({ content: 'done' });
|
||||
const plan = planSessionFork(session([user, assistant]), assistant.id);
|
||||
assert.ok(plan.ok);
|
||||
assert.doesNotMatch(plan.messages[0].attachments![0].previewText!, /tool-output-selection/);
|
||||
assert.deepEqual({ ...plan.messages[0].attachments![0], previewText: attachment.previewText }, attachment);
|
||||
assert.equal(plan.messages[0].attachments![1], image);
|
||||
assert.doesNotMatch(plan.messages[0].images![0].previewText!, /tool-output-selection/);
|
||||
assert.equal(user.attachments![0], attachment);
|
||||
assert.equal(attachment.previewText, 'Read tool-output-selection with tool_output_read');
|
||||
});
|
||||
|
||||
test('fork still rejects an unresolved retained call even when compaction covers it', () => {
|
||||
const unresolved = msg({ toolCalls: [{ id: 'pending' }], executionStatus: 'cancelled' });
|
||||
const done = msg({ content: 'later completed answer' });
|
||||
const source = session([unresolved, done], { summary: 'the earlier call was interrupted', compactedMessageCount: 1 });
|
||||
assert.deepEqual(planSessionFork(source, done.id), { ok: false, reason: 'dangling-tool-call' });
|
||||
assert.equal(getForkableMessageIds(source).has(done.id), false);
|
||||
});
|
||||
@@ -0,0 +1,276 @@
|
||||
import type { ChatMessageAttachment } from '../infrastructure/ai/types';
|
||||
import type { ProviderContinuation } from '../infrastructure/ai/providerContinuation';
|
||||
|
||||
/**
|
||||
* Pure planning logic for "Fork from here": copy an AI chat session up to a
|
||||
* completed assistant response into a new session, leaving the original
|
||||
* conversation untouched in history.
|
||||
*
|
||||
* Refusals (per the feature contract):
|
||||
* - only completed assistant-response turn boundaries fork — streaming, failed,
|
||||
* cancelled, or in-progress turns are rejected,
|
||||
* - every tool call in the retained prefix must have a paired result and
|
||||
* vice versa (no dangling calls or results on the branch),
|
||||
* - a stored compaction summary that would cover messages discarded by the
|
||||
* branch is refused (the summary must stay accurate for the fork),
|
||||
* - incomplete-turn state (status text, pending approvals) is stripped while
|
||||
* the per-message provider replay metadata is preserved, so the fresh
|
||||
* provider thread replays the retained history.
|
||||
*/
|
||||
|
||||
export type ForkRefusalReason =
|
||||
| 'message-not-found'
|
||||
| 'not-assistant'
|
||||
| 'not-completed'
|
||||
| 'dangling-tool-call'
|
||||
| 'dangling-tool-result'
|
||||
| 'compaction-covers-discarded'
|
||||
| 'source-output-reasoning';
|
||||
|
||||
/** A message type that carries the fork-relevant subset (e.g. ChatMessage). */
|
||||
export type ForkMessageLike = {
|
||||
id: string;
|
||||
role: 'user' | 'assistant' | 'system' | 'tool';
|
||||
content: string;
|
||||
thinking?: string;
|
||||
providerContinuation?: ProviderContinuation;
|
||||
attachments?: ReadonlyArray<ChatMessageAttachment>;
|
||||
images?: ReadonlyArray<ChatMessageAttachment>;
|
||||
toolCalls?: ReadonlyArray<{ id: string; name?: string; arguments?: Record<string, unknown> }>;
|
||||
toolResults?: ReadonlyArray<{ toolCallId: string; content?: string; isError?: boolean }>;
|
||||
errorInfo?: unknown;
|
||||
statusText?: string;
|
||||
pendingApproval?: unknown;
|
||||
executionStatus?: string;
|
||||
};
|
||||
|
||||
/** Structural subset of ChatMessage the fork planner needs. */
|
||||
export type ForkMessage = ForkMessageLike & { timestamp?: number };
|
||||
|
||||
export type ForkPlan<M extends ForkMessageLike = ForkMessage> =
|
||||
| {
|
||||
ok: true;
|
||||
boundaryIndex: number;
|
||||
title: string;
|
||||
messages: M[];
|
||||
contextCompaction?: ForkContextCompaction;
|
||||
}
|
||||
| { ok: false; reason: ForkRefusalReason };
|
||||
|
||||
export type ForkContextCompaction = {
|
||||
summary: string;
|
||||
compactedMessageCount: number;
|
||||
};
|
||||
|
||||
const INCOMPLETE_EXECUTION_STATUSES = new Set([
|
||||
'pending',
|
||||
'running',
|
||||
'failed',
|
||||
'cancelled',
|
||||
]);
|
||||
|
||||
function isCompletedResponse(message: ForkMessageLike): boolean {
|
||||
return message.role === 'assistant'
|
||||
&& message.errorInfo == null
|
||||
&& message.statusText == null
|
||||
&& message.pendingApproval == null
|
||||
&& !INCOMPLETE_EXECUTION_STATUSES.has(message.executionStatus ?? '');
|
||||
}
|
||||
|
||||
function hasSourceOutputReasoning(message: ForkMessageLike): boolean {
|
||||
return (message.providerContinuation?.reasoningParts ?? []).some(
|
||||
part => /\btool-output-[A-Za-z0-9-]+/.test(part.text),
|
||||
);
|
||||
}
|
||||
|
||||
/** Eligibility only: never copy/sanitize transcript content while rendering. */
|
||||
export function getForkableMessageIds(source: {
|
||||
messages: readonly ForkMessageLike[];
|
||||
contextCompaction?: ForkContextCompaction;
|
||||
}): Set<string> {
|
||||
const ids = new Set<string>();
|
||||
const pending = new Map<string, number>();
|
||||
let pendingCount = 0;
|
||||
let orphanResult = false;
|
||||
let sourceOutputReasoning = false;
|
||||
for (const [index, message] of source.messages.entries()) {
|
||||
sourceOutputReasoning ||= hasSourceOutputReasoning(message);
|
||||
for (const call of message.toolCalls ?? []) {
|
||||
if (!call.id) continue;
|
||||
pending.set(call.id, (pending.get(call.id) ?? 0) + 1);
|
||||
pendingCount += 1;
|
||||
}
|
||||
for (const result of message.toolResults ?? []) {
|
||||
if (!result.toolCallId) continue;
|
||||
const count = pending.get(result.toolCallId) ?? 0;
|
||||
if (count === 0) orphanResult = true;
|
||||
else {
|
||||
pending.set(result.toolCallId, count - 1);
|
||||
pendingCount -= 1;
|
||||
}
|
||||
}
|
||||
if (isCompletedResponse(message) && pendingCount === 0
|
||||
&& !orphanResult && !sourceOutputReasoning
|
||||
&& (source.contextCompaction?.compactedMessageCount ?? 0) <= index + 1) {
|
||||
ids.add(message.id);
|
||||
}
|
||||
}
|
||||
return ids;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate that every tool call in the retained prefix is paired with a
|
||||
* result and vice versa. Providers may reuse a tool-call id across turns, so
|
||||
* each result is consumed by the nearest preceding unresolved call with the
|
||||
* same id (same pairing rule as buildHistoricalToolReplayMaps): a result with
|
||||
* no pending call before it is dangling, and so is a call still pending after
|
||||
* the walk.
|
||||
*/
|
||||
function validateToolCallResultPairing(
|
||||
messages: readonly ForkMessageLike[],
|
||||
): 'dangling-tool-call' | 'dangling-tool-result' | null {
|
||||
const pendingCallCounts = new Map<string, number>();
|
||||
for (const message of messages) {
|
||||
for (const toolCall of message.toolCalls ?? []) {
|
||||
if (!toolCall?.id) continue;
|
||||
pendingCallCounts.set(toolCall.id, (pendingCallCounts.get(toolCall.id) ?? 0) + 1);
|
||||
}
|
||||
for (const toolResult of message.toolResults ?? []) {
|
||||
const id = toolResult?.toolCallId;
|
||||
if (!id) continue;
|
||||
const pendingCount = pendingCallCounts.get(id) ?? 0;
|
||||
if (pendingCount === 0) return 'dangling-tool-result';
|
||||
pendingCallCounts.set(id, pendingCount - 1);
|
||||
}
|
||||
}
|
||||
for (const pendingCount of pendingCallCounts.values()) {
|
||||
if (pendingCount > 0) return 'dangling-tool-call';
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Strip incomplete-turn state so the branch starts clean; per-message provider
|
||||
* continuation metadata (Responses reasoning items, OpenAI Chat assistant
|
||||
* fields, tool-call wire options) is preserved because the fork's fresh
|
||||
* provider thread replays the retained history through it and safely discards
|
||||
* exchanges whose metadata no longer matches the active provider.
|
||||
*/
|
||||
/** Keep the preview but remove unusable source-session output references. */
|
||||
function stripSourceOutputHandles(value: string): string {
|
||||
return value
|
||||
.replace(/\bhandleId=tool-output-[A-Za-z0-9-]+/g, '[saved output remains in the original chat]')
|
||||
.replace(/"handleId"\s*:\s*"tool-output-[A-Za-z0-9-]+"/g, '"handleId":null,"note":"Saved output remains in the original chat"')
|
||||
.replace(/\btool-output-[A-Za-z0-9-]+/g, '[saved output remains in the original chat]');
|
||||
}
|
||||
|
||||
function stripAttachmentPreview<A extends { previewText?: string }>(attachment: A): A {
|
||||
return attachment.previewText
|
||||
? { ...attachment, previewText: stripSourceOutputHandles(attachment.previewText) }
|
||||
: attachment;
|
||||
}
|
||||
|
||||
export function stripMessageContinuationState<M extends ForkMessageLike>(message: M): M {
|
||||
const next = {
|
||||
...message,
|
||||
content: stripSourceOutputHandles(message.content),
|
||||
...(message.attachments ? { attachments: message.attachments.map(stripAttachmentPreview) } : {}),
|
||||
...(message.images ? { images: message.images.map(stripAttachmentPreview) } : {}),
|
||||
...(message.thinking ? { thinking: stripSourceOutputHandles(message.thinking) } : {}),
|
||||
...(message.toolCalls ? {
|
||||
toolCalls: message.toolCalls.map(call => {
|
||||
if (call.name !== 'tool_output_read'
|
||||
|| typeof call.arguments?.handleId !== 'string'
|
||||
|| !call.arguments.handleId.startsWith('tool-output-')) return call;
|
||||
const args = { ...call.arguments };
|
||||
delete args.handleId;
|
||||
return { ...call, arguments: args };
|
||||
}),
|
||||
} : {}),
|
||||
...(message.toolResults ? {
|
||||
toolResults: message.toolResults.map(result => ({
|
||||
...result,
|
||||
...(result.content ? { content: stripSourceOutputHandles(result.content) } : {}),
|
||||
})),
|
||||
} : {}),
|
||||
};
|
||||
delete next.statusText;
|
||||
delete next.pendingApproval;
|
||||
return next;
|
||||
}
|
||||
|
||||
export function buildForkTitle(title: string): string {
|
||||
const base = (title ?? '').trim() || 'New Chat';
|
||||
return base.endsWith('(fork)') ? base : `${base} (fork)`;
|
||||
}
|
||||
|
||||
/** Structural subset of AISession the fork planner needs. */
|
||||
export type ForkSourceSession<M extends ForkMessageLike = ForkMessage> = {
|
||||
title: string;
|
||||
messages: readonly M[];
|
||||
contextCompaction?: ForkContextCompaction;
|
||||
};
|
||||
|
||||
/**
|
||||
* Plan a fork at `messageId`. Returns a refusal reason when the boundary is
|
||||
* not a safe completed turn, or the retained message list (trimmed of
|
||||
* incomplete-turn state, keeping provider replay metadata) plus the
|
||||
* carried-over compaction (when still accurate).
|
||||
*/
|
||||
export function planSessionFork<M extends ForkMessageLike>(
|
||||
source: ForkSourceSession<M>,
|
||||
messageId: string,
|
||||
): ForkPlan<M> {
|
||||
const boundaryIndex = source.messages.findIndex(
|
||||
(message) => message.id === messageId,
|
||||
);
|
||||
if (boundaryIndex === -1) return { ok: false, reason: 'message-not-found' };
|
||||
const boundary = source.messages[boundaryIndex];
|
||||
if (boundary.role !== 'assistant') return { ok: false, reason: 'not-assistant' };
|
||||
if (!isCompletedResponse(boundary)) return { ok: false, reason: 'not-completed' };
|
||||
|
||||
const retained = source.messages.slice(0, boundaryIndex + 1);
|
||||
|
||||
// Provider reasoning can be signed: replacing text or dropping only that
|
||||
// block can invalidate its tool exchange. Refuse this cut without changing
|
||||
// the source metadata rather than leaking an unusable source-only handle.
|
||||
if (retained.some(hasSourceOutputReasoning)) {
|
||||
return { ok: false, reason: 'source-output-reasoning' };
|
||||
}
|
||||
|
||||
const pairingRefusal = validateToolCallResultPairing(retained);
|
||||
if (pairingRefusal) return { ok: false, reason: pairingRefusal };
|
||||
|
||||
// A stored compaction summary covers the earliest `compactedMessageCount`
|
||||
// persisted messages. The summary must never cover messages discarded by
|
||||
// the branch, so refuse when its coverage would exceed the retained prefix.
|
||||
const compaction = source.contextCompaction;
|
||||
let nextCompaction: ForkContextCompaction | undefined;
|
||||
if (compaction) {
|
||||
if (compaction.compactedMessageCount > retained.length) {
|
||||
return { ok: false, reason: 'compaction-covers-discarded' };
|
||||
}
|
||||
nextCompaction = {
|
||||
summary: stripSourceOutputHandles(compaction.summary),
|
||||
compactedMessageCount: compaction.compactedMessageCount,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
boundaryIndex,
|
||||
title: buildForkTitle(source.title),
|
||||
messages: retained.map(stripMessageContinuationState),
|
||||
...(nextCompaction ? { contextCompaction: nextCompaction } : {}),
|
||||
} as ForkPlan<M>;
|
||||
}
|
||||
|
||||
export function canForkFromMessage(
|
||||
source: {
|
||||
messages: readonly ForkMessageLike[];
|
||||
contextCompaction?: ForkContextCompaction;
|
||||
},
|
||||
messageId: string,
|
||||
): boolean {
|
||||
return getForkableMessageIds(source).has(messageId);
|
||||
}
|
||||
@@ -59,6 +59,12 @@ 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" });
|
||||
|
||||
@@ -638,3 +644,87 @@ 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,6 +176,25 @@ 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.
|
||||
@@ -217,9 +236,21 @@ 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') {
|
||||
|
||||
@@ -149,3 +149,48 @@ test("normalize keeps custom (non-built-in) rules verbatim", () => {
|
||||
assert.deepEqual(rule.patterns, customRule.patterns);
|
||||
assert.equal(rule.label, customRule.label);
|
||||
});
|
||||
|
||||
test("Unix path highlighting covers terminal paths without matching relative prose or URL slashes", () => {
|
||||
const rule = getRule(DEFAULT_KEYWORD_HIGHLIGHT_RULES, "unix-path");
|
||||
for (const path of ["/sbin/iptables", "/var/log/syslog", "/usr/local/bin/g++", "~/projects/netcatty"]) {
|
||||
const matches = [...`'${path}'`.matchAll(new RegExp(rule.patterns[0], "gi"))];
|
||||
assert.deepEqual(matches.map((match) => match[0]), [path]);
|
||||
}
|
||||
for (const text of ["RELATED,ESTABLISHED", "TCP/IP", "https://example.com/log", " / ", "./foo", "../foo", "../../foo"]) {
|
||||
assert.equal(matchesAny(rule.patterns, text), false, text);
|
||||
}
|
||||
});
|
||||
|
||||
test("Unix path rule upgrades old settings while preserving disabled highlights and edits", () => {
|
||||
const legacy = DEFAULT_KEYWORD_HIGHLIGHT_RULES.filter((rule) => rule.id !== "unix-path");
|
||||
const upgraded = normalizeTerminalSettings({ keywordHighlightEnabled: false, keywordHighlightRules: legacy });
|
||||
assert.equal(upgraded.keywordHighlightEnabled, false);
|
||||
assert.equal(getRule(upgraded.keywordHighlightRules, "unix-path").enabled, true);
|
||||
|
||||
const edited = { ...getRule(upgraded.keywordHighlightRules, "unix-path"), color: "#123456", enabled: false, customized: true, patterns: ["/chosen/path"] };
|
||||
const restored = normalizeTerminalSettings({ keywordHighlightRules: [...legacy, edited] });
|
||||
assert.deepEqual(getRule(restored.keywordHighlightRules, "unix-path"), edited);
|
||||
});
|
||||
|
||||
test("Unix path highlighting keeps Unicode paths and quoted spaces together", () => {
|
||||
const rule = getRule(DEFAULT_KEYWORD_HIGHLIGHT_RULES, "unix-path");
|
||||
for (const [text, path] of [
|
||||
["/home/用户/file", "/home/用户/file"],
|
||||
['cat "/Users/me/My Projects/app"', "/Users/me/My Projects/app"],
|
||||
["cat '/home/用户/My Projects/app'", "/home/用户/My Projects/app"],
|
||||
]) {
|
||||
const matches = [...text.matchAll(new RegExp(rule.patterns[0], "gi"))];
|
||||
assert.deepEqual(matches.map((match) => match[0]), [path]);
|
||||
}
|
||||
});
|
||||
|
||||
test("Unix path highlighting keeps punctuation up to the text boundary", () => {
|
||||
const rule = getRule(DEFAULT_KEYWORD_HIGHLIGHT_RULES, "unix-path");
|
||||
for (const path of [
|
||||
"/run/user/1000/gvfs/smb-share:server=nas,share=files/report.txt",
|
||||
"/tmp/build=release/output",
|
||||
]) {
|
||||
const matches = [...path.matchAll(new RegExp(rule.patterns[0], "gi"))];
|
||||
assert.deepEqual(matches.map((match) => match[0]), [path]);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,94 @@
|
||||
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);
|
||||
});
|
||||
@@ -0,0 +1,107 @@
|
||||
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 || '~',
|
||||
};
|
||||
};
|
||||
@@ -288,6 +288,7 @@ export const DEFAULT_KEYWORD_HIGHLIGHT_RULES: KeywordHighlightRule[] = [
|
||||
{ id: 'info', label: 'Info', patterns: ['\\[info\\]', '\\[notice\\]', '\\[note\\]', '\\bnotice\\b', '\\bnote\\b'], color: '#3B82F6', enabled: true },
|
||||
{ id: 'debug', label: 'Debug', patterns: ['\\[debug\\]', '\\[trace\\]', '\\[verbose\\]', '\\bdebug\\b', '\\btrace\\b', '\\bverbose\\b'], color: '#A78BFA', enabled: true },
|
||||
{ id: 'ip-mac', label: 'URL, IP & MAC', patterns: [URL_HIGHLIGHT_PATTERN, IPV4_HIGHLIGHT_PATTERN, IPV6_HIGHLIGHT_PATTERN, MAC_ADDRESS_HIGHLIGHT_PATTERN], color: '#EC4899', enabled: true },
|
||||
{ id: 'unix-path', label: 'Unix paths', patterns: ["(?<=\")(?:/|~/)[^\"\\r\\n]+(?=\")|(?<=')(?:/|~/)[^'\\r\\n]+(?=')|(?<![\\w.:/\"'\\u0080-\\uFFFF])(?:/|~/)[^\\s\"']+"], color: '#A78BFA', enabled: true },
|
||||
];
|
||||
|
||||
const cloneKeywordHighlightRule = (rule: KeywordHighlightRule): KeywordHighlightRule => ({
|
||||
|
||||
@@ -864,3 +864,32 @@ 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 ? { localShell: session.localShell } : {}),
|
||||
...(session.localShell !== undefined ? { localShell: session.localShell } : {}),
|
||||
...(session.localShellArgs ? { localShellArgs: [...session.localShellArgs] } : {}),
|
||||
...(session.localShellName ? { localShellName: session.localShellName } : {}),
|
||||
...(session.localShellIcon ? { localShellIcon: session.localShellIcon } : {}),
|
||||
...(session.localShellName !== undefined ? { localShellName: session.localShellName } : {}),
|
||||
...(session.localShellIcon !== undefined ? { 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 } : {}),
|
||||
...(readString(value, "localShell") ? { localShell: readString(value, "localShell") } : {}),
|
||||
...(readStringAllowEmpty(value, "localShell") !== undefined ? { localShell: readStringAllowEmpty(value, "localShell") } : {}),
|
||||
...(Array.isArray(value.localShellArgs) ? { localShellArgs: value.localShellArgs.filter((arg): arg is string => typeof arg === "string") } : {}),
|
||||
...(readString(value, "localShellName") ? { localShellName: readString(value, "localShellName") } : {}),
|
||||
...(readString(value, "localShellIcon") ? { localShellIcon: readString(value, "localShellIcon") } : {}),
|
||||
...(readStringAllowEmpty(value, "localShellName") !== undefined ? { localShellName: readStringAllowEmpty(value, "localShellName") } : {}),
|
||||
...(readStringAllowEmpty(value, "localShellIcon") !== undefined ? { localShellIcon: readStringAllowEmpty(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,6 +46,9 @@ 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,6 +173,79 @@ 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: {
|
||||
@@ -488,3 +561,77 @@ 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,6 +2,8 @@ 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"];
|
||||
|
||||
@@ -10,6 +12,8 @@ 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 {
|
||||
@@ -18,6 +22,8 @@ interface ResolveTerminalSessionHostOptions {
|
||||
groupConfigs: GroupConfig[];
|
||||
proxyProfiles: ProxyProfile[];
|
||||
localOs: LocalOs;
|
||||
/** When provided, saved local hosts with no shell resolve the Settings shell. */
|
||||
defaultLocalShell?: ResolveDefaultLocalShellContext;
|
||||
}
|
||||
|
||||
interface ResolveTerminalChainHostsOptions {
|
||||
@@ -41,14 +47,18 @@ 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 })
|
||||
: {};
|
||||
return materializeHostProxyProfile(
|
||||
const effectiveHost = materializeHostProxyProfile(
|
||||
applyGroupDefaults(host, groupDefaults, { validProxyProfileIds }),
|
||||
proxyProfiles,
|
||||
);
|
||||
return defaultLocalShell
|
||||
? resolveHostDefaultLocalShell(effectiveHost, defaultLocalShell)
|
||||
: effectiveHost;
|
||||
}
|
||||
|
||||
const suppressDeviceTypeForShellTransport = (host: Host): Host => {
|
||||
@@ -86,12 +96,30 @@ 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);
|
||||
@@ -100,6 +128,7 @@ export function resolveTerminalSessionHost({
|
||||
host: vaultHost,
|
||||
groupConfigs,
|
||||
proxyProfiles,
|
||||
defaultLocalShell,
|
||||
});
|
||||
|
||||
const protocol = session.protocol ?? existingHost.protocol;
|
||||
@@ -126,10 +155,12 @@ export function resolveTerminalSessionHost({
|
||||
JSON.stringify(pluginConnection) === JSON.stringify(existingHost.pluginConnection) &&
|
||||
backspaceBehavior === existingHost.backspaceBehavior
|
||||
) {
|
||||
return suppressDeviceTypeForShellTransport(existingHost);
|
||||
return suppressDeviceTypeForShellTransport(
|
||||
overlaySessionLocalShell(session, existingHost),
|
||||
);
|
||||
}
|
||||
|
||||
return suppressDeviceTypeForShellTransport({
|
||||
return suppressDeviceTypeForShellTransport(overlaySessionLocalShell(session, {
|
||||
...existingHost,
|
||||
protocol,
|
||||
port,
|
||||
@@ -137,7 +168,7 @@ export function resolveTerminalSessionHost({
|
||||
etEnabled,
|
||||
pluginConnection,
|
||||
backspaceBehavior,
|
||||
});
|
||||
}));
|
||||
}
|
||||
|
||||
export function resolveTerminalChainHosts({
|
||||
@@ -152,7 +183,9 @@ export function resolveTerminalChainHosts({
|
||||
return host.hostChain.hostIds
|
||||
.map((hostId) => {
|
||||
const chainHost = hostMap.get(hostId);
|
||||
if (!chainHost) return undefined;
|
||||
// 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;
|
||||
return resolveEffectiveTerminalHost({
|
||||
host: chainHost,
|
||||
groupConfigs,
|
||||
|
||||
@@ -11,3 +11,18 @@ 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,6 +41,28 @@ 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,19 +185,36 @@ const parseKeyPath = (draft: VaultHostDraft): string | undefined => {
|
||||
export const buildVaultHostEndpointKey = (
|
||||
host: Pick<Host, 'hostname' | 'port' | 'username' | 'protocol'>,
|
||||
): string =>
|
||||
`${(host.protocol ?? 'ssh').toLowerCase()}|${host.hostname.toLowerCase()}|${host.port}|${(host.username ?? '').toLowerCase()}`;
|
||||
// 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()}`;
|
||||
|
||||
/**
|
||||
* 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'>,
|
||||
host: Pick<Host, 'hostname' | 'port' | 'username' | 'protocol' | 'group'>
|
||||
& Partial<Pick<Host, 'os' | 'localShell' | 'localShellArgs' | 'localShellName' | 'localStartDir'>>,
|
||||
): 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) ?? '';
|
||||
return `${buildVaultHostEndpointKey(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}`;
|
||||
};
|
||||
|
||||
export function buildVaultHostFromDraft(
|
||||
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
filterVaultImportKeyPassphrasesAgainstExisting,
|
||||
resolveVaultImportKeyPassphraseConflicts,
|
||||
} from "./vaultImport.ts";
|
||||
import { encodeCsvPassphrase } from "./vaultImport/csvCredentialFields.ts";
|
||||
import { encodeCsvPassphrase, encodeCsvLocalShell } from "./vaultImport/csvCredentialFields.ts";
|
||||
import type { Host } from "./models.ts";
|
||||
|
||||
const mobaXtermSshSession = (
|
||||
@@ -527,6 +527,73 @@ 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",
|
||||
|
||||
+26
-2
@@ -15,7 +15,7 @@ export {
|
||||
} from "./vaultHostCreate";
|
||||
import { parseQuickConnectInput } from "./quickConnect";
|
||||
import { findExactHeaderIndex, findHeaderIndex, parseCsv } from "./vaultImport/csvUtils";
|
||||
import { decodeCsvKeyPath, decodeCsvPassphrase } from "./vaultImport/csvCredentialFields";
|
||||
import { decodeCsvKeyPath, decodeCsvLocalShell, decodeCsvPassphrase } from "./vaultImport/csvCredentialFields";
|
||||
import { parseCsvProxy } from "./vaultImport/csvProxy";
|
||||
import { attachMobaXtermPasswords } from "./vaultImport/mobaXtermPasswords";
|
||||
import { decodeFinalShellPassword } from "./finalShellPassword";
|
||||
@@ -326,7 +326,9 @@ 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[] = [];
|
||||
@@ -446,6 +448,7 @@ 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"]);
|
||||
@@ -537,6 +540,15 @@ 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",
|
||||
@@ -564,6 +576,14 @@ 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);
|
||||
@@ -586,6 +606,10 @@ 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,6 +2,7 @@ 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)
|
||||
@@ -41,3 +42,48 @@ 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,6 +55,32 @@ 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,
|
||||
@@ -425,3 +451,67 @@ 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 { encodeCsvKeyPath, encodeCsvPassphrase, encodeCsvProxy } from './csvCredentialFields';
|
||||
import { CsvLocalShellSpec, encodeCsvKeyPath, encodeCsvLocalShell, 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"];
|
||||
const header = ["Groups", "Label", "Tags", "Notes", "Hostname/IP", "Protocol", "Port", "Username", "Password", "KeyPath", "Passphrase", "Proxy", "LocalShell"];
|
||||
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,8 +53,32 @@ 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"];
|
||||
const header = ["Groups", "Label", "Tags", "Notes", "Hostname/IP", "Protocol", "Port", "Username", "Password", "KeyPath", "Passphrase", "Proxy", "LocalShell"];
|
||||
const rows: string[][] = [header];
|
||||
let unreadableProxyCredentialCount = 0;
|
||||
|
||||
@@ -118,6 +142,7 @@ const exportHostsToCsv = (hosts: Host[], options: VaultCsvExportOptions) => {
|
||||
const proxyValue = proxyConfig
|
||||
? formatCsvProxy(resolveProxyConfigAuth(proxyConfig, options.identities))
|
||||
: "";
|
||||
const localShellSpec = getLocalShellSpec(host);
|
||||
|
||||
rows.push([
|
||||
host.group ?? "",
|
||||
@@ -132,6 +157,7 @@ const exportHostsToCsv = (hosts: Host[], options: VaultCsvExportOptions) => {
|
||||
encodeCsvKeyPath(keyPath),
|
||||
encodeCsvPassphrase(passphrase),
|
||||
encodeCsvProxy(proxyValue),
|
||||
localShellSpec ? encodeCsvLocalShell(localShellSpec) : "",
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
@@ -6,7 +6,17 @@ 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 "${marker}_P:%s\\n" "$name"`;
|
||||
+ `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) {
|
||||
// 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.
|
||||
@@ -14,10 +24,7 @@ function buildLiveShellProbe(marker) {
|
||||
const { dispatcher } = bashHistoryScratchNames(marker);
|
||||
const clear = `[ -z "\${${dispatcher}-}" ]||$${dispatcher} unset ${dispatcher}`;
|
||||
const fallback = `[ "\${${dispatcher}-}" = command ]||{ ${cleanup}; };${clear}`;
|
||||
// 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`;
|
||||
|
||||
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`;
|
||||
}
|
||||
|
||||
// Interactive bash prints PS2 (`> ` by default) on backslash continuations.
|
||||
@@ -35,11 +42,12 @@ 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 };
|
||||
return { kind: null, shellName: "" };
|
||||
}
|
||||
|
||||
module.exports = { buildLiveShellProbe, parseLiveShellProbe, normalizeLiveShellProbeLine };
|
||||
module.exports = { buildLiveShellProbe, buildLiveShellProbeCleanup, parseLiveShellProbe, normalizeLiveShellProbeLine };
|
||||
@@ -1,7 +1,7 @@
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const { EventEmitter } = require('node:events');
|
||||
const { buildLiveShellProbe, parseLiveShellProbe } = require('./liveShellProbe.cjs');
|
||||
const { buildLiveShellProbe, buildLiveShellProbeCleanup, 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' });
|
||||
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' });
|
||||
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' });
|
||||
});
|
||||
|
||||
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)
|
||||
+ buildLiveShellProbe(marker) + buildLiveShellProbeCleanup(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) + '\nprintf "\\n"\ncommand builtin history\nexit\n',
|
||||
+ buildLiveShellProbe(marker) + buildLiveShellProbeCleanup(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)
|
||||
+ buildLiveShellProbe(marker) + buildLiveShellProbeCleanup(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 probe stops writing after ${stop}`, async () => {
|
||||
test(`paced Bash probe cleanup stops writing after ${stop}`, async () => {
|
||||
const pty = new EventEmitter();
|
||||
const writes = [];
|
||||
pty.write = data => writes.push(data);
|
||||
@@ -414,8 +414,9 @@ 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 >= 2, 'probe must have started a later chunk');
|
||||
assert.ok(writes.length >= 3, 'cleanup must have started a later chunk');
|
||||
if (stop === 'cancel') {
|
||||
job.cancel();
|
||||
pty.emit('close');
|
||||
@@ -427,3 +428,19 @@ 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, parseLiveShellProbe } = require("./liveShellProbe.cjs");
|
||||
const { buildLiveShellProbe, buildLiveShellProbeCleanup, parseLiveShellProbe } = require("./liveShellProbe.cjs");
|
||||
|
||||
const DEFAULT_FOREGROUND_PTY_CAPTURE_CHARS = 1024 * 1024;
|
||||
const END_MARKER_PROMPT_WAIT_MS = 30000;
|
||||
@@ -560,7 +560,9 @@ function startPtyJob(ptyStream, command, options) {
|
||||
probeOutput = "";
|
||||
if (probe.kind) resolvedShellKind = probe.kind;
|
||||
if (finished || cancelRequested) return;
|
||||
writeWrappedCommand();
|
||||
const isZsh = /^zsh(?:[-.][0-9].*)?$/i.test(probe.shellName);
|
||||
const skipProbeCleanup = probe.kind === "fish" || isZsh;
|
||||
writeWrappedCommand(skipProbeCleanup ? "" : buildLiveShellProbeCleanup(marker), isZsh);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -822,9 +824,14 @@ function startPtyJob(ptyStream, command, options) {
|
||||
writeNext();
|
||||
}
|
||||
|
||||
function writeWrappedCommand() {
|
||||
const wrapped = buildWrappedCommand(command, resolvedShellKind, marker, probeLiveShell);
|
||||
writeInput(`${(skipPendingInputClear ? "" : buildPendingInputClearPrefix(resolvedShellKind))}${wrapped}`);
|
||||
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}`);
|
||||
}
|
||||
|
||||
// Prime the renderer's display suppression before the first byte is typed
|
||||
|
||||
@@ -967,6 +967,64 @@ test("posix wrapper keeps cd contained in the subshell (documented trade-off)",
|
||||
}
|
||||
});
|
||||
|
||||
// Issue #3485: interactive bash 4.4+ expands PS0 after reading a command and
|
||||
// before executing it, so a user-defined PS0 hook (commonly a timestamp from
|
||||
// ~/.bashrc; root usually has none) prints extra output inside the hidden
|
||||
// wrapper's echo window. The wrapper must silence it for its own reads and
|
||||
// restore the original value afterwards.
|
||||
test("posix wrapper saves PS0 before wrapping and restores it after execution", () => {
|
||||
const marker = "__NCMCP_TEST__";
|
||||
const wrapped = buildWrappedCommand("echo PS0_HOOK_TEST", "posix", marker);
|
||||
assert.match(wrapped, /\[ -n "\$\{PS0-\}" \]&&\{ __NCMCP_PS0=\$\{PS0-\}; PS0=; \}; printf/);
|
||||
assert.match(
|
||||
wrapped,
|
||||
/\[ -n "\$\{__NCMCP_PS0-\}" \]&&\{ PS0=\$\{__NCMCP_PS0-\}; unset __NCMCP_PS0; \}; \(exit \$__NCMCP_rc\); \}/,
|
||||
);
|
||||
|
||||
const result = spawnSync(
|
||||
"bash",
|
||||
["--norc", "-c", 'PS0="[hook] "\n' + wrapped + 'printf "RESTORED_PS0=%s\\n" "$PS0"'],
|
||||
{ encoding: "utf8" },
|
||||
);
|
||||
|
||||
assert.equal(result.error, undefined);
|
||||
assert.equal(result.status, 0);
|
||||
assert.match(result.stdout, new RegExp(`${marker}_E:0`));
|
||||
assert.match(result.stdout, /PS0_HOOK_TEST/);
|
||||
// The user's PS0 value is back once the wrapper group finishes, so their
|
||||
// own subsequent commands still run the hook.
|
||||
assert.match(result.stdout, /RESTORED_PS0=\[hook\] /);
|
||||
});
|
||||
|
||||
test("posix wrapper leaves PS0 alone when the user has none set", () => {
|
||||
const marker = "__NCMCP_TEST__";
|
||||
const wrapped = buildWrappedCommand("echo NO_PS0_TEST", "posix", marker);
|
||||
const result = spawnSync(
|
||||
"bash",
|
||||
["--norc", "-c", `unset PS0\n${wrapped}printf "PS0_SET=[%s]\\n" "\${PS0-}"`],
|
||||
{ encoding: "utf8" },
|
||||
);
|
||||
|
||||
assert.equal(result.error, undefined);
|
||||
assert.equal(result.status, 0);
|
||||
assert.match(result.stdout, new RegExp(`${marker}_E:0`));
|
||||
// Nothing was saved and nothing is restored: PS0 stays unset afterwards.
|
||||
assert.match(result.stdout, /NO_PS0_TEST/);
|
||||
const ps0Line = result.stdout.split("\n").find((line) => line.startsWith("PS0_SET="));
|
||||
assert.equal(ps0Line, "PS0_SET=[]");
|
||||
});
|
||||
|
||||
test("posix wrapper silencing still works under sh (dash) where PS0 does not exist", () => {
|
||||
const marker = "__NCMCP_TEST__";
|
||||
const wrapped = buildWrappedCommand("echo DASH_TEST", "posix", marker);
|
||||
const result = spawnSync("sh", ["-c", `${wrapped}printf 'SH_ALIVE\\n'`], { encoding: "utf8" });
|
||||
|
||||
assert.equal(result.error, undefined);
|
||||
assert.equal(result.status, 0);
|
||||
assert.match(result.stdout, new RegExp(`${marker}_E:0`));
|
||||
assert.match(result.stdout, /SH_ALIVE/);
|
||||
});
|
||||
|
||||
// Issue #3265: with plain HISTCONTROL the wrapper line is recorded in bash
|
||||
// history; arrow-up recall then redraws the marker text, the preload __NCMCP_
|
||||
// filter suppresses the redraw fragments containing it, and readline's row
|
||||
|
||||
@@ -243,7 +243,7 @@ function buildBashHistoryCleanup(marker, keepDispatcher = false) {
|
||||
].join(` \\\n: "${marker}"; `);
|
||||
}
|
||||
|
||||
function buildPosixWrapperBody(command, marker, startFormat) {
|
||||
function buildPosixWrapperBody(command, marker, startFormat, { omitBashHistoryCleanup = false } = {}) {
|
||||
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,21 +273,32 @@ function buildPosixWrapperBody(command, marker, startFormat) {
|
||||
}
|
||||
cmdAssign = `${marker}_cmd=$(${writes.join(`; \\\n: '${marker}'; `)})`;
|
||||
}
|
||||
const historyCleanup = buildBashHistoryCleanup(marker);
|
||||
const historyCleanup = omitBashHistoryCleanup ? [] : [buildBashHistoryCleanup(marker)];
|
||||
// Issue #3485: interactive bash 4.4+ expands PS0 after reading a command
|
||||
// and before executing it. A user-set PS0 hook (commonly a pre-exec
|
||||
// timestamp/hook defined in ~/.bashrc — root usually has none) prints extra
|
||||
// output inside the hidden wrapper's echo window, garbling the agent's
|
||||
// command echo while manual typing renders natively. Save PS0 only while it
|
||||
// is currently set (so an interrupted run cannot overwrite the saved value
|
||||
// with empty), silence it for every wrapper read, and restore it after the
|
||||
// wrapper group finishes. zsh/dash/fish have no PS0, so both guards are
|
||||
// no-ops there.
|
||||
const ps0Save = '[ -n "${PS0-}" ]&&{ __NCMCP_PS0=${PS0-}; PS0=; }';
|
||||
const ps0Restore = '[ -n "${__NCMCP_PS0-}" ]&&{ PS0=${__NCMCP_PS0-}; unset __NCMCP_PS0; }';
|
||||
// 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
|
||||
// continuation. A leading newline separates it from concurrent PTY echo.
|
||||
return `${marker}=0; printf '\\n%s\\n' '${marker}_I'\n` + [
|
||||
return `${marker}=0; ${ps0Save}; printf '\\n%s\\n' '${marker}_I'\n` + [
|
||||
` : '${marker}'; ${cmdAssign}`,
|
||||
`{ printf '${startFormat}' '${marker}_S'; trap ':' INT; ( ${noPager}eval "$${marker}_cmd" ); __NCMCP_rc=$?; trap - INT`,
|
||||
`printf '%s\\n' '${marker}_E:'"$__NCMCP_rc"`,
|
||||
historyCleanup,
|
||||
`(exit $__NCMCP_rc); }`,
|
||||
...historyCleanup,
|
||||
`${ps0Restore}; (exit $__NCMCP_rc); }`,
|
||||
].join(`; \\\n: '${marker}'; `);
|
||||
}
|
||||
|
||||
function buildWrappedCommand(command, shellKind, marker, separateStartMarker = false) {
|
||||
function buildWrappedCommand(command, shellKind, marker, separateStartMarker = false, options = {}) {
|
||||
// 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 +377,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")}\n`;
|
||||
return ` ${buildPosixWrapperBody(command, marker, "\\n%s\\n", options)}\n`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,7 +13,7 @@ const path = require("node:path");
|
||||
// ── ANSI / URL regexes ──
|
||||
|
||||
const ANSI_ESCAPE_REGEX = /\u001B\[[0-?]*[ -/]*[@-~]/g;
|
||||
const ANSI_OSC_REGEX = /\u001B\][^\u0007]*(?:\u0007|\u001B\\)/g;
|
||||
const ANSI_OSC_REGEX = /\u001B\][^\u0007]*?(?:\u0007|\u001B\\)/g;
|
||||
const URL_CANDIDATE_REGEX = /https?:\/\/[^\s]+/g;
|
||||
const WINDOWS_RUNNABLE_EXTENSIONS = [".exe", ".cmd", ".bat", ".com"];
|
||||
const MAX_PROMPT_TRACK_TAIL = 4096;
|
||||
|
||||
@@ -2,6 +2,7 @@ const test = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
|
||||
const {
|
||||
stripAnsi,
|
||||
addCodexExecutableEnvForSdk,
|
||||
buildWindowsShellCommandLine,
|
||||
extractTrailingIdlePrompt,
|
||||
@@ -28,6 +29,19 @@ const fs = require("node:fs");
|
||||
const os = require("node:os");
|
||||
const path = require("node:path");
|
||||
|
||||
test("OSC stripping preserves text between independently terminated sequences", () => {
|
||||
for (const firstEnd of ["\x07", "\x1b\\"]) {
|
||||
for (const secondEnd of ["\x07", "\x1b\\"]) {
|
||||
const text = `\x1b]3008;start${firstEnd}\nprobe-reply\n\x1b]3008;end${secondEnd}prompt`;
|
||||
assert.equal(stripAnsi(text), "\nprobe-reply\nprompt");
|
||||
}
|
||||
}
|
||||
assert.equal(
|
||||
stripAnsi("before\x1b[31mcolored\x1b[0m\x1b]0;title\x07after"),
|
||||
"beforecoloredafter",
|
||||
);
|
||||
});
|
||||
|
||||
test("formatSyntheticEcho normalizes multi-line commands to CRLF so xterm doesn't staircase", () => {
|
||||
assert.equal(
|
||||
formatSyntheticEcho("set -e\ncd /tmp\necho done"),
|
||||
|
||||
@@ -560,6 +560,7 @@ async function runOpenCodeTurn({
|
||||
let opencode = null;
|
||||
let sessionId = resumeSessionId || null;
|
||||
let hasContent = false;
|
||||
let backgroundWarningCount = 0;
|
||||
let failed = false;
|
||||
let abortSent = false;
|
||||
let removeAbortListener = null;
|
||||
@@ -619,6 +620,13 @@ async function runOpenCodeTurn({
|
||||
if (abortController?.signal?.aborted) break;
|
||||
const eventSessionId = getOpenCodeSessionIdFromEvent(event);
|
||||
if (eventSessionId && eventSessionId !== sessionId) continue;
|
||||
if (!eventSessionId && getOpenCodeEventPayload(event)?.type === "session.error") {
|
||||
const message = extractOpenCodeErrorMessage(getOpenCodeEventPayload(event).properties?.error)
|
||||
|| "OpenCode reported a background error";
|
||||
if (emitter.warning) emitter.warning(`opencode-background-error:${++backgroundWarningCount}`, message);
|
||||
else emitter.status(message);
|
||||
continue;
|
||||
}
|
||||
const result = translateOpenCodeEvent(event, emitter, state);
|
||||
if (result.content) hasContent = true;
|
||||
if (result.error) {
|
||||
|
||||
@@ -500,6 +500,52 @@ test("runOpenCodeTurn continues after a tool error part and reaches session.idle
|
||||
assert.equal(events.some((event) => event.k === "error"), false);
|
||||
});
|
||||
|
||||
for (const scoped of [false, true]) {
|
||||
test(`runOpenCodeTurn ${scoped ? "fails on the current session error" : "warns about plugin errors without stopping the session"} (#3173)`, async () => {
|
||||
const { events, emitter } = collector();
|
||||
emitter.warning = (id, message) => events.push({ k: "warning", id, message });
|
||||
const message = scoped ? "bad key" : "Failed to install plugin: invalid package name";
|
||||
const stream = {
|
||||
async *[Symbol.asyncIterator]() {
|
||||
yield {
|
||||
type: "session.error",
|
||||
properties: { ...(scoped ? { sessionID: "sess-1" } : {}), error: { data: { message } } },
|
||||
};
|
||||
if (!scoped) {
|
||||
yield {
|
||||
type: "session.error",
|
||||
properties: { error: { data: { message: "Failed to install another plugin" } } },
|
||||
};
|
||||
}
|
||||
yield {
|
||||
type: "message.part.updated",
|
||||
properties: { part: { type: "text", sessionID: "sess-1", id: "p1", text: "reply" }, delta: "reply" },
|
||||
};
|
||||
yield { type: "session.idle", properties: { sessionID: "sess-1" } };
|
||||
},
|
||||
};
|
||||
await runOpenCodeTurn({
|
||||
prompt: "continue",
|
||||
resumeSessionId: "sess-1",
|
||||
emitter,
|
||||
openCodeFactory: async () => ({
|
||||
client: {
|
||||
global: { event: async () => ({ stream }) },
|
||||
session: { promptAsync: async () => ({ data: true }) },
|
||||
},
|
||||
server: { close() {} },
|
||||
}),
|
||||
});
|
||||
assert.equal(events.some((event) => event.k === "error" && event.m === message), scoped);
|
||||
assert.equal(events.some((event) => event.k === "warning" && event.message === message), !scoped);
|
||||
assert.equal(events.some((event) => event.k === "text" && event.t === "reply"), !scoped);
|
||||
assert.equal(events.some((event) => event.k === "done"), !scoped);
|
||||
const warnings = events.filter((event) => event.k === "warning");
|
||||
assert.equal(warnings.length, scoped ? 0 : 2);
|
||||
assert.equal(new Set(warnings.map((event) => event.id)).size, warnings.length);
|
||||
});
|
||||
}
|
||||
|
||||
test("mapOpenCodeModels flattens providers", () => {
|
||||
assert.deepEqual(mapOpenCodeModels({
|
||||
providers: [
|
||||
|
||||
@@ -727,6 +727,15 @@ function syncLiveSessionsToExternalScope(chatSessionId = EXTERNAL_MCP_CHAT_SESSI
|
||||
}
|
||||
return { ok: true, count: 0, chatSessionId };
|
||||
}
|
||||
// A reconnecting renderer tab can be absent from the backend map while
|
||||
// another terminal is still live. Preserve its already scoped metadata;
|
||||
// authoritative renderer updates remove it when the tab actually closes.
|
||||
const liveIds = new Set(sessionList.map((entry) => entry.sessionId));
|
||||
for (const [sessionId, meta] of existingMeta) {
|
||||
if (!liveIds.has(sessionId) && meta.connected === false) {
|
||||
sessionList.push({ sessionId, ...meta });
|
||||
}
|
||||
}
|
||||
updateSessionMetadata(sessionList, chatSessionId);
|
||||
return { ok: true, count: sessionList.length, chatSessionId };
|
||||
}
|
||||
|
||||
@@ -142,3 +142,21 @@ test("getContext still hides disconnected sessions outside the agent scope", asy
|
||||
});
|
||||
assert.equal(environment.hosts.length, 0);
|
||||
});
|
||||
|
||||
test("external context preserves reconnecting metadata beside another live transport", async (t) => {
|
||||
const bridge = setup(t, { sessions: new Map([["online", { hostname: "online", stream: { write() {} } }]]) });
|
||||
const scope = bridge.EXTERNAL_MCP_CHAT_SESSION_ID;
|
||||
bridge.setExternalMcpHooks({ isEnabled: () => true });
|
||||
bridge.updateSessionMetadata([
|
||||
{ sessionId: "online", hostname: "online", connected: true },
|
||||
{ sessionId: "retrying", hostname: "retrying", connected: false },
|
||||
], scope);
|
||||
const context = await bridge.dispatchBuiltinRpc("netcatty/getContext", { chatSessionId: scope });
|
||||
assert.equal(context.hostCount, 2);
|
||||
assert.equal(context.hosts.find(host => host.sessionId === "retrying")?.connected, false);
|
||||
assert.equal(context.hosts.find(host => host.sessionId === "online")?.connected, true);
|
||||
// A renderer-authoritative removal must still remove the closed tab.
|
||||
bridge.updateSessionMetadata([{ sessionId: "online", connected: true }], scope);
|
||||
const afterClose = await bridge.dispatchBuiltinRpc("netcatty/getContext", { chatSessionId: scope });
|
||||
assert.deepEqual(afterClose.hosts.map(host => host.sessionId), ["online"]);
|
||||
});
|
||||
@@ -3058,6 +3058,10 @@ 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);
|
||||
};
|
||||
|
||||
@@ -3114,6 +3118,10 @@ 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();
|
||||
@@ -3123,6 +3131,16 @@ 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) => {
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
"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);
|
||||
});
|
||||
}
|
||||
@@ -87,6 +87,32 @@ test('MCP host_open forwards the schema input through RPC and vault service to t
|
||||
const editedTarget = await run({ hostId: host.id });
|
||||
assert.notEqual(editedTarget.sessionId, recovered.sessionId, 'a saved endpoint edit must not reuse the old transport');
|
||||
assert.equal(editedTarget.reused, undefined);
|
||||
tabs.length = 0;
|
||||
host.protocol = 'telnet';
|
||||
host.telnetPort = 2323;
|
||||
host.telnetUsername = 'telnet-user';
|
||||
host.telnetPassword = 'telnet-password';
|
||||
const telnet = await run({ hostId: host.id });
|
||||
tabs[0].status = 'connected';
|
||||
assert.equal((await run({ hostId: host.id })).sessionId, telnet.sessionId);
|
||||
host.telnetPort = 2324;
|
||||
const changedTelnet = await run({ hostId: host.id });
|
||||
assert.notEqual(changedTelnet.sessionId, telnet.sessionId, 'the actual Telnet destination port change requires a new transport');
|
||||
assert.equal(changedTelnet.reused, undefined);
|
||||
// Current-main fields only: saved-local creation/default snapshots remain #3616's responsibility.
|
||||
tabs.length = 0;
|
||||
host.protocol = 'local';
|
||||
host.hostname = 'localhost';
|
||||
host.localShell = '/bin/zsh';
|
||||
host.localShellArgs = ['-l'];
|
||||
host.localStartDir = '/original';
|
||||
const local = await run({ hostId: host.id });
|
||||
tabs[0].status = 'connected';
|
||||
assert.equal((await run({ hostId: host.id })).sessionId, local.sessionId);
|
||||
host.localStartDir = '/replacement';
|
||||
const changedLocal = await run({ hostId: host.id });
|
||||
assert.notEqual(changedLocal.sessionId, local.sessionId, 'an explicit saved local directory change must not reuse the old process');
|
||||
assert.equal(changedLocal.reused, undefined);
|
||||
// Saved serial hosts use the same first-party host_open path and factory.
|
||||
tabs.length = 0;
|
||||
host.protocol = 'serial';
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import { generateText, streamText } from "ai";
|
||||
import { buildCattySdkMessages, createContinuationContext } from "./harness/turnDrivers/cattyMessageBuilder";
|
||||
import { ToolOutputStore } from "./harness/toolOutputStore";
|
||||
|
||||
import { createModelFromConfig, resolveProviderEndpoint } from "./sdk/providers";
|
||||
import type { ProviderConfig } from "./types";
|
||||
@@ -280,3 +283,43 @@ test("SDK requests forward custom headers and scope OpenCode sessions", async (t
|
||||
}
|
||||
assert.equal(requests.length, 16);
|
||||
});
|
||||
|
||||
test("OpenAI Chat sends inline image attachments as data URLs through the bridge", async (t) => {
|
||||
const browserGlobal = globalThis as typeof globalThis & { window?: unknown };
|
||||
const originalWindow = browserGlobal.window;
|
||||
t.after(() => { browserGlobal.window = originalWindow; });
|
||||
const requests: Array<{ url: string; body: Record<string, unknown> }> = [];
|
||||
Reflect.set(globalThis, "window", { netcatty: {
|
||||
aiFetch: async (url: string, _method: string, _headers: Record<string, string>, body: string) => {
|
||||
requests.push({ url, body: JSON.parse(body) });
|
||||
return { ok: false, status: 400, data: '{"error":{"message":"captured request"}}' };
|
||||
},
|
||||
aiChatStream: async (_requestId: string, url: string, _headers: Record<string, string>, body: string) => {
|
||||
requests.push({ url, body: JSON.parse(body) });
|
||||
return { ok: false, error: "captured request" };
|
||||
},
|
||||
onAiStreamData: () => () => {},
|
||||
onAiStreamEnd: () => () => {},
|
||||
onAiStreamError: () => () => {},
|
||||
} });
|
||||
|
||||
for (const [mediaType, base64Data] of [["image/png", "iVBORw0KGgo="], ["image/jpeg", "/9j/4A=="]]) {
|
||||
const messages = buildCattySdkMessages({
|
||||
allMessages: [], includeCurrentUserMessage: true, trimmed: "Read this image",
|
||||
attachments: [{ mediaType, base64Data }],
|
||||
continuationContext: createContinuationContext("p", "openai", "m", false),
|
||||
chatSessionId: "images", toolOutputStore: new ToolOutputStore(), fieldsByMessage: new Map(),
|
||||
});
|
||||
const model = createModelFromConfig(makeConfig({ openaiApi: "chat", apiKey: "test-key", baseURL: "https://example.test/v1" }));
|
||||
await assert.rejects(generateText({ model, messages, maxRetries: 0 }), /captured request/);
|
||||
await assert.rejects(async () => await streamText({ model, messages, maxRetries: 0, onError: () => {} }).text);
|
||||
for (const request of requests.slice(-2)) {
|
||||
assert.equal(request.url, "https://example.test/v1/chat/completions");
|
||||
assert.deepEqual(request.body.messages, [{ role: "user", content: [
|
||||
{ type: "text", text: "Read this image" },
|
||||
{ type: "image_url", image_url: { url: `data:${mediaType};base64,${base64Data}` } },
|
||||
] }]);
|
||||
}
|
||||
}
|
||||
assert.equal(requests.length, 4);
|
||||
});
|
||||
@@ -1077,6 +1077,132 @@ for (const rejects of [false, true]) {
|
||||
});
|
||||
}
|
||||
|
||||
for (const rejects of [false, true]) {
|
||||
test(`final failure notification and ${rejects ? "rejection" : "reply"} cannot restart an exhausted cycle`, async (t) => {
|
||||
let statusListener: ((status: PortForwardingRule["status"], error?: string) => void) | undefined;
|
||||
let forceFinalAttempt = true;
|
||||
const reconnectRule = rule({ id: `final-double-failure-${rejects}`, autoStart: true });
|
||||
Object.defineProperty(globalThis, "window", {
|
||||
configurable: true,
|
||||
value: { netcatty: {
|
||||
startPortForward: async () => {
|
||||
if (forceFinalAttempt) getActiveConnection(reconnectRule.id)!.reconnectAttempts = 5;
|
||||
statusListener?.("error", "SSH connection failed");
|
||||
if (rejects) throw new Error("SSH connection failed");
|
||||
return { success: false, error: "SSH connection failed" };
|
||||
},
|
||||
stopPortForwardByRuleId: async () => ({ stopped: 1, failed: 0, errors: [] }),
|
||||
onPortForwardStatus: (_id: string, listener: typeof statusListener) => {
|
||||
statusListener = listener;
|
||||
return () => undefined;
|
||||
},
|
||||
} },
|
||||
});
|
||||
let reconnects = 0;
|
||||
setReconnectCallback(async () => { reconnects += 1; return { success: true }; });
|
||||
t.after(async () => {
|
||||
setReconnectCallback(null);
|
||||
await stopAndCleanupRuleAndWait(reconnectRule.id);
|
||||
});
|
||||
t.mock.timers.enable({ apis: ["setTimeout"] });
|
||||
await startPortForward(reconnectRule, host(), [], [], [], () => undefined, true);
|
||||
t.mock.timers.tick(3000);
|
||||
assert.equal(reconnects, 0);
|
||||
assert.equal(resetReconnectAttempts(reconnectRule.id), true);
|
||||
|
||||
// An explicit restart must still get a new retry cycle if it fails.
|
||||
forceFinalAttempt = false;
|
||||
await startPortForward(reconnectRule, host(), [], [], [], () => undefined, true);
|
||||
assert.equal(getActiveConnection(reconnectRule.id)?.reconnectAttempts, 1);
|
||||
t.mock.timers.tick(3000);
|
||||
assert.equal(reconnects, 1);
|
||||
});
|
||||
}
|
||||
|
||||
for (const adoption of ["sync", "reconcile"] as const) {
|
||||
test(`${adoption} starts a fresh retry cycle when adopting an active backend tunnel`, async (t) => {
|
||||
const reconnectRule = rule({ id: `exhausted-adoption-${adoption}`, autoStart: true });
|
||||
const listeners = new Map<string, (status: PortForwardingRule["status"]) => void>();
|
||||
const backendTunnel = { ruleId: reconnectRule.id, tunnelId: "fresh-backend-tunnel", status: "active" };
|
||||
Object.defineProperty(globalThis, "window", {
|
||||
configurable: true,
|
||||
value: { netcatty: {
|
||||
startPortForward: async () => ({ success: true }),
|
||||
listPortForwards: async () => [backendTunnel],
|
||||
stopPortForwardByRuleId: async () => ({ stopped: 1, failed: 0, errors: [] }),
|
||||
onPortForwardStatus: (id: string, listener: (status: PortForwardingRule["status"]) => void) => {
|
||||
listeners.set(id, listener);
|
||||
return () => undefined;
|
||||
},
|
||||
} },
|
||||
});
|
||||
let reconnects = 0;
|
||||
setReconnectCallback(async () => { reconnects += 1; return { success: true }; }, () => true);
|
||||
t.after(async () => {
|
||||
setReconnectCallback(null);
|
||||
await stopAndCleanupRuleAndWait(reconnectRule.id);
|
||||
});
|
||||
t.mock.timers.enable({ apis: ["setTimeout"] });
|
||||
await startPortForward(reconnectRule, host(), [], [], [], () => undefined, true);
|
||||
const oldConnection = getActiveConnection(reconnectRule.id)!;
|
||||
oldConnection.reconnectAttempts = 5;
|
||||
const oldListener = listeners.get(oldConnection.tunnelId)!;
|
||||
oldListener("error");
|
||||
oldListener("inactive");
|
||||
assert.equal(resetReconnectAttempts(reconnectRule.id), true);
|
||||
|
||||
if (adoption === "sync") await syncWithBackend({ shouldReconnect: () => true });
|
||||
else await reconcileWithBackend();
|
||||
assert.equal(resetReconnectAttempts(reconnectRule.id), false);
|
||||
const replacement = getActiveConnection(reconnectRule.id)!;
|
||||
assert.equal(replacement.tunnelId, backendTunnel.tunnelId);
|
||||
oldListener("error");
|
||||
assert.equal(getActiveConnection(reconnectRule.id), replacement);
|
||||
listeners.get(backendTunnel.tunnelId)!("inactive");
|
||||
assert.equal(getActiveConnection(reconnectRule.id)?.reconnectAttempts, 1);
|
||||
t.mock.timers.tick(3000);
|
||||
assert.equal(reconnects, 1);
|
||||
});
|
||||
}
|
||||
|
||||
for (const adoption of ["sync", "reconcile"] as const) {
|
||||
for (const rejects of [false, true]) {
|
||||
test(`${adoption} preserves exhaustion if backend adoption ${rejects ? "rejects" : "finds a stopped tunnel"}`, async (t) => {
|
||||
const reconnectRule = rule({ id: `failed-adoption-${adoption}-${rejects}`, autoStart: true });
|
||||
let statusListener: ((status: PortForwardingRule["status"]) => void) | undefined;
|
||||
Object.defineProperty(globalThis, "window", {
|
||||
configurable: true,
|
||||
value: { netcatty: {
|
||||
startPortForward: async () => ({ success: true }),
|
||||
listPortForwards: async () => [{ ruleId: reconnectRule.id, tunnelId: "stopped-before-subscribe", status: "active" }],
|
||||
subscribePortForward: async () => {
|
||||
if (rejects) throw new Error("backend unavailable during adoption");
|
||||
return { tunnelId: "stopped-before-subscribe", status: "inactive" };
|
||||
},
|
||||
stopPortForwardByRuleId: async () => ({ stopped: 1, failed: 0, errors: [] }),
|
||||
onPortForwardStatus: (_id: string, listener: typeof statusListener) => {
|
||||
statusListener = listener;
|
||||
return () => undefined;
|
||||
},
|
||||
} },
|
||||
});
|
||||
setReconnectCallback(async () => ({ success: true }), () => true);
|
||||
t.after(async () => {
|
||||
setReconnectCallback(null);
|
||||
await stopAndCleanupRuleAndWait(reconnectRule.id);
|
||||
});
|
||||
await startPortForward(reconnectRule, host(), [], [], [], () => undefined, true);
|
||||
getActiveConnection(reconnectRule.id)!.reconnectAttempts = 5;
|
||||
statusListener!("error");
|
||||
statusListener!("inactive");
|
||||
assert.equal(resetReconnectAttempts(reconnectRule.id), true);
|
||||
if (adoption === "sync") await syncWithBackend({ shouldReconnect: () => true });
|
||||
else await reconcileWithBackend();
|
||||
assert.equal(resetReconnectAttempts(reconnectRule.id), true);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
test("inactive close events preserve an already scheduled reconnect", async (t) => {
|
||||
let statusListener: ((status: PortForwardingRule["status"], error?: string | null) => void) | undefined;
|
||||
Object.defineProperty(globalThis, "window", {
|
||||
|
||||
@@ -234,6 +234,10 @@ const scheduleReconnectIfNeeded = (
|
||||
return true;
|
||||
}
|
||||
|
||||
// One failed dial can report both a status event and a rejected start.
|
||||
// Once exhausted, neither notification may begin another retry cycle.
|
||||
if (exhaustedReconnectRules.has(ruleId)) return false;
|
||||
|
||||
const currentConn = activeConnections.get(ruleId);
|
||||
if (currentConn?.reconnectSuppressed) return false;
|
||||
if (currentConn?.reconnectTimerCallback) {
|
||||
@@ -681,6 +685,10 @@ const subscribeSyncedConnection = async (
|
||||
connection.status = resolveBackendStatus(snapshot.status);
|
||||
connection.error = snapshot.error;
|
||||
}
|
||||
if (connection.status === 'active') {
|
||||
exhaustedReconnectRules.delete(ruleId);
|
||||
connection.reconnectAttempts = 0;
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
@@ -820,6 +828,10 @@ export const reconcileWithBackend = async (): Promise<{
|
||||
if (existing.status !== backendStatus || existing.error !== tunnel.error) {
|
||||
existing.status = backendStatus;
|
||||
existing.error = tunnel.error;
|
||||
if (backendStatus === 'active') {
|
||||
exhaustedReconnectRules.delete(ruleId);
|
||||
existing.reconnectAttempts = 0;
|
||||
}
|
||||
result.appeared.push(ruleId);
|
||||
}
|
||||
}
|
||||
@@ -893,7 +905,9 @@ export const startPortForward = async (
|
||||
|
||||
// Clear any existing reconnect timer
|
||||
clearReconnectTimer(rule.id);
|
||||
|
||||
// Explicit starts (including network recovery) authorize a fresh cycle.
|
||||
exhaustedReconnectRules.delete(rule.id);
|
||||
|
||||
if (!bridge?.startPortForward) {
|
||||
// Fallback for browser/dev mode - simulate the connection
|
||||
logger.warn('[PortForwardingService] Backend not available, simulating connection...');
|
||||
|
||||
Loaded 100 of 107 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user