Compare commits

...
Author SHA1 Message Date
bincxz ee97b508f1 fix: preserve each OpenCode background warning 2026-10-15 14:47:50 +08:00
bincxz e57eeebc1e fix: keep OpenCode plugin errors from stopping resumed chats 2026-10-15 14:34:33 +08:00
Netcatty-Bot 6c2bf43ee5 fix(vault): let local shell (CMD) sessions be saved into groups (#3616)
Related to #3615 (stays open: hiding/restoring running sessions is separate work).

Merged by judgment after ~49 Codex findings; remaining P2 (Copy Tab name/icon snapshot when host shell is edited after launch) is a cosmetic edge case for a follow-up.
2026-10-15 14:13:46 +08:00
陈大猫 abdf254702 fix(sftp): report downloads interrupted by a closed channel
Report a closed SFTP channel instead of leaving a download waiting for a source-file open callback that cannot arrive. Keep existing upload and reconnect policies.

Related to #3406. The original connection-loss cause and reporter environment remain unconfirmed; keep the issue open.

Validation: 42 download checks and 7 lifecycle/cancellation checks; real 1 GiB normal download, pause/continue, and interrupted retry with full checksums; exact-head Codex review clean; current test and four platform package jobs successful.
2026-10-15 13:45:06 +08:00
70 changed files with 2684 additions and 133 deletions

No files matched your search

+67
View File
@@ -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);
});
+21 -5
View File
@@ -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);
}
}
+14 -3
View File
@@ -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', () => {
+17 -3
View File
@@ -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');
});
}
+31 -19
View File
@@ -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);
+4 -1
View File
@@ -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);
+1
View File
@@ -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',
+15
View File
@@ -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',
+1
View File
@@ -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',
+15
View File
@@ -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',
+16
View File
@@ -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': 'Экспорт',
@@ -1051,6 +1052,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': 'Провайдеры подключения не установлены',
+1
View File
@@ -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': '导出',
+15
View File
@@ -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': '未安装连接提供程序',
+1
View File
@@ -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': '匯出',
+15
View File
@@ -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");
});
+47
View File
@@ -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, "~");
}
});
+7 -1
View File
@@ -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(
@@ -6,7 +6,9 @@ import type { GroupConfig, Host, Identity, SSHKey, TerminalSession } from '../..
import { updateRestoredSessionStatusState } from './sessionRestoreState';
import { resolveEffectiveTerminalHost, resolveTerminalSessionHost } from '../../domain/terminalHostResolution';
import { createHostTerminalSession } from './sessionFactories';
import { resolveHostDefaultLocalShell } from '../../domain/localShellHost';
import { useHostOpenSessionReuse } from './useHostOpenSessionReuse';
import { resolveShellSetting } from '../../lib/useDiscoveredShells';
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
const effective = (host: Host) => host;
@@ -231,14 +233,14 @@ for (const [field, value] of [
});
}
test('local default shell and cwd treat empty values as omitted but explicit empty args stay distinct', async (t) => {
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(), undefined, 'empty args bypass backend login-shell defaults');
assert.equal((await api.find())?.sessionId, 'tab', 'the renderer omits empty args so backend login defaults still apply');
});
for (const [label, proxy] of [
@@ -272,3 +274,110 @@ test('Telnet inheriting a proxy profile cannot reuse its previous direct session
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');
});
+31 -14
View File
@@ -17,9 +17,11 @@ 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') {
@@ -27,9 +29,10 @@ export function useHostOpenSessionReuse({ sessions, hosts, keys, identities, res
hostname: host.hostname,
username: '',
localShell: host.localShell || undefined,
// undefined lets the backend choose login arguments; [] explicitly does not.
localShellArgs: host.localShellArgs,
localStartDir: host.localStartDir || undefined,
// 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 });
}
@@ -95,8 +98,9 @@ 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.protocol === 'serial' ? resolveEffectiveHost(host).username : session.username,
port: session.port ?? host.port,
@@ -105,12 +109,18 @@ export function useHostOpenSessionReuse({ sessions, hosts, keys, identities, res
etEnabled: session.etEnabled ?? host.etEnabled,
serialConfig: session.serialConfig ?? host.serialConfig,
pluginConnection: session.pluginConnection ?? host.pluginConnection,
localShell: session.localShell ?? host.localShell,
localShellArgs: session.localShellArgs ?? host.localShellArgs,
localStartDir: session.localStartDir ?? host.localStartDir,
}).catch(() => undefined));
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]);
@@ -120,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.
+6 -11
View File
@@ -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);
+7 -1
View File
@@ -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) => {
+8 -1
View File
@@ -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(() => {
+5 -1
View File
@@ -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 \?\?/);
});
+255
View File
@@ -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;
+4
View File
@@ -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>
+10 -2
View File
@@ -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),
+1
View File
@@ -132,6 +132,7 @@ export const SnippetsRightPanel: React.FC<SnippetsRightPanelProps> = ({
customGroups={customGroups}
selectedHostIds={targetSelection}
multiSelect={true}
allowLocalHosts={true}
onSelect={handleTargetSelect}
onSelectionChange={handleTargetSelectionChange}
onBack={handleTargetPickerBack}
+7 -3
View File
@@ -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) {
+31
View File
@@ -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' })), []);
+8 -1
View File
@@ -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,
+111
View File
@@ -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);
});
+101 -12
View File
@@ -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);
},
@@ -1355,7 +1437,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 +1553,9 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
handleHostConnect,
handleImportFileSelected,
handleNewHost,
handleNewLocalShellHost,
handleCancelLocalShellHost,
handleSaveLocalShellHost,
handleProtocolSelect,
handleQuickConnect,
handleQuickConnectSaveHost,
@@ -1488,6 +1575,8 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
isDeleteGroupOpen,
isGroupPanelOpen,
isHostPanelOpen,
isLocalShellHostPanelOpen,
editingLocalShellHost,
isHostsSectionActive,
isImportOpen,
isMultiSelectMode,
+4 -2
View File
@@ -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", () => {
+1
View File
@@ -200,6 +200,7 @@ export const ScriptEditorModal: React.FC<ScriptEditorModalProps> = ({
customGroups={customGroups}
selectedHostIds={selectedHostIds}
multiSelect
allowLocalHosts={true}
onSelect={onSelectHost}
onSelectionChange={onSelectionChange}
onConfirm={handleTargetsConfirm}
+6 -4
View File
@@ -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",
+5 -4
View File
@@ -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)}
+48 -12
View File
@@ -12,6 +12,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 +173,7 @@ export function VaultViewLayout({ ctx }: { ctx: VaultViewLayoutContext }) {
Edit2,
editingGroupPath,
editingHost,
editingLocalShellHost,
editingHostGroupDefaults,
FileCode,
FileSymlink,
@@ -195,6 +197,9 @@ export function VaultViewLayout({ ctx }: { ctx: VaultViewLayoutContext }) {
handleHostConnect,
handleImportFileSelected,
handleNewHost,
handleNewLocalShellHost,
handleCancelLocalShellHost,
handleSaveLocalShellHost,
handleProtocolSelect,
handleQuickConnect,
handleQuickConnectSaveHost,
@@ -214,6 +219,7 @@ export function VaultViewLayout({ ctx }: { ctx: VaultViewLayoutContext }) {
isDeleteGroupOpen,
isGroupPanelOpen,
isHostPanelOpen,
isLocalShellHostPanelOpen,
isHostsSectionActive,
isImportOpen,
isMultiSelectMode,
@@ -380,6 +386,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
@@ -521,7 +530,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 +539,7 @@ export function VaultViewLayout({ ctx }: { ctx: VaultViewLayoutContext }) {
) {
activeElement.blur();
}
}, [isHostPanelOpen]);
}, [isHostAsideOpen]);
return (
<div
@@ -961,12 +970,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 +983,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 +992,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 +1045,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 +1067,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>
@@ -1527,6 +1543,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>
+90
View File
@@ -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");
});
+31
View File
@@ -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') {
+94
View File
@@ -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);
});
+107
View File
@@ -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 || '~',
};
};
+11
View File
@@ -864,3 +864,14 @@ 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, "~");
});
+2 -2
View File
@@ -152,7 +152,7 @@ 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 } : {}),
@@ -195,7 +195,7 @@ 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") } : {}),
+3
View File
@@ -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;
}
+147
View File
@@ -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"]);
});
+38 -5
View File
@@ -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,
+15
View File
@@ -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';
}
+22
View File
@@ -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',
+20 -3
View File
@@ -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(
+68 -1
View File
@@ -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
View File
@@ -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();
+46
View File
@@ -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;
}
};
+90
View File
@@ -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")));
});
+29 -3
View File
@@ -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) : "",
]);
}
@@ -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: [
+18
View File
@@ -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);
});
}
+47 -2
View File
@@ -7,6 +7,11 @@ const CMD_SHELLS = new Set(localShellRules.cmdShells);
const FISH_SHELLS = new Set(localShellRules.fishShells);
const POSIX_SHELLS = new Set(localShellRules.posixShells);
const WSL_SHELLS = new Set(localShellRules.wslShells);
// Discovered-shell IDs the local-shell editor persists (they are not executable
// basenames): every WSL distro ID is "wsl-<distro>", and Git Bash / Cygwin
// launch a POSIX bash, so these values classify as "posix" by ID form.
const DISCOVERED_POSIX_SHELL_ID_PREFIXES = localShellRules.discoveredPosixShellIdPrefixes;
const DISCOVERED_POSIX_SHELL_IDS = new Set(localShellRules.discoveredPosixShellIds);
function getExecutableBaseName(filePath) {
const normalized = String(filePath || "").trim();
@@ -15,6 +20,29 @@ function getExecutableBaseName(filePath) {
return (parts[parts.length - 1] || "").toLowerCase();
}
/**
* Recover the executable basename from a disambiguated Unix discovery ID.
*
* `discoverUnixShells()` (shellDiscovery.cjs) uses the bare basename as the
* discovered-shell ID only while it is unique; when multiple installations
* share a basename (e.g. /bin/bash vs /opt/homebrew/bin/bash) it falls back
* to a path slug: the absolute shell path with separators replaced by "-"
* ("bin-bash", "opt-homebrew-bin-bash"). Such IDs are stored as the host's
* `localShell` value, so they must resolve like the backend does (which maps
* the ID to the real command at spawn time) before classification.
*
* Only slug-shaped values (no separators, contains "-") are considered, and
* only when the final slug segment names a recognizable shell, so custom
* commands like "custom-shell" still classify as "unknown".
*/
function resolveDisambiguatedUnixShellIdBase(shellName) {
if (!shellName || /[\\/]/.test(shellName) || !shellName.includes("-")) {
return shellName;
}
const segments = shellName.split("-");
return segments[segments.length - 1] || shellName;
}
function detectLocalOs(platformLike) {
const platform = String(platformLike || "").toLowerCase();
if (platform.includes("mac")) return "macos";
@@ -45,13 +73,30 @@ function isWindowsAppExecutionAliasPath(filePath) {
return normalize(filePath).startsWith(aliasDir);
}
function classifyLocalShellType(shellPath, platformLike) {
const shellName = getExecutableBaseName(shellPath);
function classifyShellBaseName(shellName) {
if (POWERSHELL_SHELLS.has(shellName)) return "powershell";
if (CMD_SHELLS.has(shellName)) return "cmd";
if (FISH_SHELLS.has(shellName)) return "fish";
if (POSIX_SHELLS.has(shellName)) return "posix";
if (WSL_SHELLS.has(shellName)) return "posix";
if (
DISCOVERED_POSIX_SHELL_ID_PREFIXES.some((prefix) => shellName.startsWith(prefix)) ||
DISCOVERED_POSIX_SHELL_IDS.has(shellName)
) {
return "posix";
}
return undefined;
}
function classifyLocalShellType(shellPath, platformLike) {
const shellName = getExecutableBaseName(shellPath);
const byBaseName = classifyShellBaseName(shellName);
if (byBaseName) return byBaseName;
const slugBaseName = resolveDisambiguatedUnixShellIdBase(shellName);
if (slugBaseName !== shellName) {
const bySlugBaseName = classifyShellBaseName(slugBaseName);
if (bySlugBaseName) return bySlugBaseName;
}
if (!shellName) {
return detectLocalOs(platformLike) === "windows" ? "powershell" : "posix";
}
+9
View File
@@ -17,7 +17,16 @@ test("local shell classification is shared between renderer and CommonJS bridge"
["C:\\Windows\\System32\\cmd.exe", "Win32", "cmd"],
["C:\\Windows\\System32\\wsl.exe", "Win32", "posix"],
["C:\\msys64\\usr\\bin\\bash.exe", "Win32", "posix"],
["wsl-ubuntu", "Win32", "posix"],
["git-bash", "Win32", "posix"],
["cygwin", "Win32", "posix"],
["fish", "linux", "fish"],
// Disambiguated Unix discovery IDs are path slugs (shell path with
// separators replaced by "-"); classification resolves their basename.
["bin-bash", "Darwin", "posix"],
["opt-homebrew-bin-bash", "Darwin", "posix"],
["usr-local-bin-zsh", "Darwin", "posix"],
["opt-homebrew-bin-fish", "Darwin", "fish"],
["", "Win32", "powershell"],
[undefined, "MacIntel", "posix"],
["custom-shell", "linux", "unknown"],
+50 -5
View File
@@ -8,6 +8,11 @@ const CMD_SHELLS = new Set(localShellRules.cmdShells);
const FISH_SHELLS = new Set(localShellRules.fishShells);
const POSIX_SHELLS = new Set(localShellRules.posixShells);
const WSL_SHELLS = new Set(localShellRules.wslShells);
// Discovered-shell IDs the local-shell editor persists (they are not executable
// basenames): every WSL distro ID is "wsl-<distro>", and Git Bash / Cygwin
// launch a POSIX bash, so these values classify as "posix" by ID form.
const DISCOVERED_POSIX_SHELL_ID_PREFIXES: string[] = localShellRules.discoveredPosixShellIdPrefixes;
const DISCOVERED_POSIX_SHELL_IDS = new Set(localShellRules.discoveredPosixShellIds);
function getExecutableBaseName(filePath: string | undefined) {
const normalized = String(filePath || "").trim();
@@ -16,6 +21,29 @@ function getExecutableBaseName(filePath: string | undefined) {
return (parts[parts.length - 1] || "").toLowerCase();
}
/**
* Recover the executable basename from a disambiguated Unix discovery ID.
*
* `discoverUnixShells()` (shellDiscovery.cjs) uses the bare basename as the
* discovered-shell ID only while it is unique; when multiple installations
* share a basename (e.g. /bin/bash vs /opt/homebrew/bin/bash) it falls back
* to a path slug: the absolute shell path with separators replaced by "-"
* ("bin-bash", "opt-homebrew-bin-bash"). Such IDs are stored as the host's
* `localShell` value, so they must resolve like the backend does (which maps
* the ID to the real command at spawn time) before classification.
*
* Only slug-shaped values (no separators, contains "-") are considered, and
* only when the final slug segment names a recognizable shell, so custom
* commands like "custom-shell" still classify as "unknown".
*/
function resolveDisambiguatedUnixShellIdBase(shellName: string): string {
if (!shellName || /[\\/]/.test(shellName) || !shellName.includes("-")) {
return shellName;
}
const segments = shellName.split("-");
return segments[segments.length - 1] || shellName;
}
export function detectLocalOs(platformLike?: string): LocalOs {
const platform = String(platformLike || "").toLowerCase();
if (platform.includes("mac")) return "macos";
@@ -24,16 +52,33 @@ export function detectLocalOs(platformLike?: string): LocalOs {
return "linux";
}
export function classifyLocalShellType(
shellPath: string | undefined,
platformLike?: string,
): LocalShellType {
const shellName = getExecutableBaseName(shellPath);
function classifyShellBaseName(shellName: string): LocalShellType | undefined {
if (POWERSHELL_SHELLS.has(shellName)) return "powershell";
if (CMD_SHELLS.has(shellName)) return "cmd";
if (FISH_SHELLS.has(shellName)) return "fish";
if (POSIX_SHELLS.has(shellName)) return "posix";
if (WSL_SHELLS.has(shellName)) return "posix";
if (
DISCOVERED_POSIX_SHELL_ID_PREFIXES.some((prefix) => shellName.startsWith(prefix)) ||
DISCOVERED_POSIX_SHELL_IDS.has(shellName)
) {
return "posix";
}
return undefined;
}
export function classifyLocalShellType(
shellPath: string | undefined,
platformLike?: string,
): LocalShellType {
const shellName = getExecutableBaseName(shellPath);
const byBaseName = classifyShellBaseName(shellName);
if (byBaseName) return byBaseName;
const slugBaseName = resolveDisambiguatedUnixShellIdBase(shellName);
if (slugBaseName !== shellName) {
const bySlugBaseName = classifyShellBaseName(slugBaseName);
if (bySlugBaseName) return bySlugBaseName;
}
if (!shellName) {
return detectLocalOs(platformLike) === "windows" ? "powershell" : "posix";
}
+3 -1
View File
@@ -3,5 +3,7 @@
"cmdShells": ["cmd", "cmd.exe"],
"fishShells": ["fish"],
"posixShells": ["sh", "bash", "zsh", "ksh", "dash", "ash", "bash.exe"],
"wslShells": ["wsl", "wsl.exe"]
"wslShells": ["wsl", "wsl.exe"],
"discoveredPosixShellIdPrefixes": ["wsl-"],
"discoveredPosixShellIds": ["git-bash", "cygwin"]
}
+17 -1
View File
@@ -1,5 +1,6 @@
import { useEffect, useState } from "react";
import { useEffect, useMemo, useState } from "react";
import { netcattyBridge } from "../infrastructure/services/netcattyBridge";
import type { ResolveDefaultLocalShellContext } from "../domain/localShellHost";
let shellCache: DiscoveredShell[] | null = null;
let shellPromise: Promise<DiscoveredShell[]> | null = null;
@@ -208,3 +209,18 @@ export function getShellIconPath(iconId: string): string {
export function isMonochromeShellIcon(iconId: string): boolean {
return DISTRO_ICONS.has(iconId);
}
/**
* Build the context consumed by `resolveHostDefaultLocalShell` (Settings shell
* + discovered shells + resolver) so every effective-host/session-creation
* boundary can resolve saved local hosts saved with "System default shell".
*/
export function useDefaultLocalShellContext(
terminalSettings?: { localShell?: string; localShellArgs?: string[]; localStartDir?: string },
): ResolveDefaultLocalShellContext {
const discoveredShells = useDiscoveredShells();
return useMemo(
() => ({ discoveredShells, resolveShellSetting, terminalSettings }),
[discoveredShells, terminalSettings],
);
}