mirror of
https://github.com/binaricat/Netcatty.git
synced 2026-11-08 00:50:06 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
62eb8cbf42 | ||
|
|
f56cc29afe | ||
|
|
0a50705bb5 | ||
|
|
24253d6406 | ||
|
|
17b113091c | ||
|
|
e47378614a | ||
|
|
3a190e6a9e | ||
|
|
c9e22779a1 | ||
|
|
7c278f1c55 | ||
|
|
0ce1eb88be | ||
|
|
ceea6f2758 | ||
|
|
6d911712a6 | ||
|
|
69a8b1a0a2 | ||
|
|
24f390c332 | ||
|
|
cd7f182401 | ||
|
|
260b06540f | ||
|
|
57089a3abf | ||
|
|
3c67392455 |
No files matched your search
@@ -0,0 +1,21 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import test from "node:test";
|
||||
|
||||
const source = readFileSync(new URL("./AppSideEffects.tsx", import.meta.url), "utf8");
|
||||
|
||||
test("handleConnectToHost reads temporary connection settings at call time", () => {
|
||||
assert.match(source, /const temporaryConnectionSettingsRef = useRef\(terminalSettings\)/);
|
||||
assert.match(source, /temporaryConnectionSettingsRef\.current = terminalSettings/);
|
||||
|
||||
const handlerStart = source.indexOf("const handleConnectToHost = useCallback");
|
||||
const handlerEnd = source.indexOf("const handleConnectToHostWithTabName");
|
||||
assert.ok(handlerStart >= 0 && handlerEnd > handlerStart);
|
||||
const handler = source.slice(handlerStart, handlerEnd);
|
||||
|
||||
assert.match(handler, /temporaryConnectionSettingsRef\.current/);
|
||||
assert.match(handler, /temporaryConnectionPrepareOptions\(liveTerminalSettings\)/);
|
||||
assert.match(handler, /terminalSettings: liveTerminalSettings/);
|
||||
assert.doesNotMatch(handler, /terminalSettings\.temporaryConnections/);
|
||||
assert.doesNotMatch(handler, /\[addConnectionLog, connectToHost, resolveEffectiveHost, identities, keys, discoveredShells, terminalSettings\]/);
|
||||
});
|
||||
@@ -47,7 +47,7 @@ import { buildSshDeepLinkConnectionHost, buildSshDeepLinkEphemeralHost, buildSsh
|
||||
import { buildTelnetDeepLinkConnectionHost, buildTelnetDeepLinkEphemeralHostFromSaved, buildTelnetDeepLinkOpenHost, findTelnetDeepLinkHost, materializeTelnetDeepLinkMatchHost, parseTelnetDeepLink } from '../../domain/telnetDeepLink';
|
||||
import { buildJmsDeepLinkEphemeralHost, isSupportedJmsProtocol, parseJmsDeepLink } from '../../domain/jmsDeepLink';
|
||||
import { applyEphemeralHostDistroUpdate, applyEphemeralHostsUpdate, splitHostsUpdateByEphemeral } from '../../domain/ephemeralHosts';
|
||||
import { prepareConnectionHost } from '../../domain/legacyAlgorithms';
|
||||
import { prepareConnectionHost, temporaryConnectionPrepareOptions } from '../../domain/legacyAlgorithms';
|
||||
import { resolveHostAuth } from '../../domain/sshAuth';
|
||||
import { isEncryptedCredentialPlaceholder, stripSyncPayloadEncryptedCredentials } from '../../domain/credentials';
|
||||
import {
|
||||
@@ -1449,11 +1449,18 @@ export function AppSideEffects() {
|
||||
name,
|
||||
), [createWorkspaceFromTargets, resolveEffectiveHost]);
|
||||
|
||||
// VaultView memo ignores onConnect identity, so this callback can stay
|
||||
// closed over an older render. Temporary-connection toggles are read here,
|
||||
// at connect time, so turning them on or off applies without a reload.
|
||||
const temporaryConnectionSettingsRef = useRef(terminalSettings);
|
||||
temporaryConnectionSettingsRef.current = terminalSettings;
|
||||
|
||||
// Wrapper to connect to host with logging
|
||||
const handleConnectToHost = useCallback((host: Host, alreadyEffective = false, hidden = false) => {
|
||||
const liveTerminalSettings = temporaryConnectionSettingsRef.current;
|
||||
const connectionHost = prepareConnectionHost(
|
||||
host,
|
||||
terminalSettings.temporaryConnectionsAllowLegacyAlgorithms,
|
||||
temporaryConnectionPrepareOptions(liveTerminalSettings),
|
||||
);
|
||||
if (connectionHost.ephemeral) {
|
||||
setEphemeralHosts((previous) => {
|
||||
@@ -1476,9 +1483,9 @@ export function AppSideEffects() {
|
||||
resolveHostAuth,
|
||||
resolveShellSetting,
|
||||
systemInfoRef,
|
||||
terminalSettings,
|
||||
terminalSettings: liveTerminalSettings,
|
||||
}), connectionHost, hidden);
|
||||
}, [addConnectionLog, connectToHost, resolveEffectiveHost, identities, keys, discoveredShells, terminalSettings]);
|
||||
}, [addConnectionLog, connectToHost, resolveEffectiveHost, identities, keys, discoveredShells]);
|
||||
|
||||
const handleConnectToHostWithTabName = useCallback((host: Host, tabName?: string) => {
|
||||
const sessionId = handleConnectToHost(host);
|
||||
|
||||
@@ -22,6 +22,7 @@ import { Input } from '../../components/ui/input';
|
||||
import { Label } from '../../components/ui/label';
|
||||
import { LazyLoadBoundary } from '../../components/ui/lazy-load-boundary';
|
||||
import { toast } from '../../components/ui/toast';
|
||||
import { openQuickConnectFromInput } from '../quickConnectBridge';
|
||||
import { AppHostTreeLayer } from './AppHostTreeLayer';
|
||||
import { AppHostEditorLayer } from './AppHostEditorLayer';
|
||||
import { AppPluginKeybindingHost } from './AppPluginKeybindingHost';
|
||||
@@ -376,6 +377,14 @@ function AppViewInner({ domains }: AppViewProps) {
|
||||
appendHostToWorkspace(workspaceId, resolveWorkspaceAppendHost(host), rootDir);
|
||||
}, [appendHostToWorkspace, hosts, resolveWorkspaceAppendHost, workspaces]);
|
||||
|
||||
const pendingKeyboardRequest = keyboardInteractiveQueue[0] || null;
|
||||
const pendingTotpUri = pendingKeyboardRequest?.totpUri;
|
||||
const handleFillTotp = useCallback(async () => {
|
||||
if (!pendingTotpUri) return null;
|
||||
const result = await netcattyBridge.get()?.previewTotp?.({ uri: pendingTotpUri });
|
||||
return result?.ok && result.code ? result.code : null;
|
||||
}, [pendingTotpUri]);
|
||||
|
||||
const isPeerSessionWindow = typeof window !== 'undefined'
|
||||
&& window.location.hash.startsWith('#/session-window');
|
||||
const externalMcpToggle = useExternalMcpToggleState();
|
||||
@@ -930,6 +939,7 @@ function AppViewInner({ domains }: AppViewProps) {
|
||||
showSftpTab={showSftpTab}
|
||||
onQueryChange={setQuickSearch}
|
||||
onSelect={handleHostConnectWithProtocolCheck}
|
||||
onQuickConnect={openQuickConnectFromInput}
|
||||
onEditHost={(host) => {
|
||||
setIsQuickSwitcherOpen(false);
|
||||
setQuickSearch('');
|
||||
@@ -1022,6 +1032,8 @@ function AppViewInner({ domains }: AppViewProps) {
|
||||
request={keyboardInteractiveQueue[0] || null}
|
||||
onSubmit={handleKeyboardInteractiveSubmit}
|
||||
onCancel={handleKeyboardInteractiveCancel}
|
||||
canFillTotp={Boolean(pendingTotpUri)}
|
||||
onFillTotp={handleFillTotp}
|
||||
/>
|
||||
{/* Indicator when more 2FA requests are pending */}
|
||||
{keyboardInteractiveQueue.length > 1 && (
|
||||
|
||||
@@ -554,6 +554,9 @@ export function useAppStartupEffects(ctx: StartupEffectsContext) {
|
||||
hostname: request.hostname,
|
||||
savedPassword: request.savedPassword,
|
||||
allowSavePassword: request.allowSavePassword !== false,
|
||||
...(typeof request.totpUri === "string" && request.totpUri && !request.totpUri.startsWith("enc:v1:")
|
||||
? { totpUri: request.totpUri }
|
||||
: {}),
|
||||
}]);
|
||||
});
|
||||
const unsubscribeCancelled = bridge.onKeyboardInteractiveCancelled?.((event) => {
|
||||
|
||||
@@ -289,6 +289,14 @@ export const enAiMessages: Messages = {
|
||||
'ai.opencode.customPathPlaceholder': 'e.g. /usr/local/bin/opencode',
|
||||
'ai.opencode.check': 'Check',
|
||||
'ai.opencode.resetPath': 'Reset',
|
||||
'ai.opencode.v2.startupTimeout': 'OpenCode 2 did not become ready in time. Confirm `opencode serve` works in a terminal, then retry.',
|
||||
'ai.opencode.v2.exited': 'OpenCode 2 stopped before the chat could start.',
|
||||
'ai.opencode.v2.unauthorized': 'OpenCode 2 refused the connection. Retry the chat.',
|
||||
'ai.opencode.v2.unavailable': 'OpenCode 2 could not be reached. Check that it is installed and try again.',
|
||||
'ai.opencode.v2.sessionFailed': 'OpenCode 2 could not finish this turn.',
|
||||
'ai.opencode.v2.empty': 'OpenCode 2 returned an empty response. Run `opencode` in a terminal to configure authentication and models.',
|
||||
'ai.opencode.v2.contextSkipped': 'OpenCode 2 could not apply the Netcatty conversation context.',
|
||||
'ai.opencode.v2.mcpSkipped': 'OpenCode 2 could not attach an MCP tool. The chat will continue without it.',
|
||||
|
||||
// AI MiMo Code (Xiaomi MiMo Code CLI — OpenCode fork)
|
||||
'ai.mimo.title': 'MiMo Code',
|
||||
|
||||
@@ -838,6 +838,8 @@ Highlight the focused split pane:
|
||||
'settings.terminal.connection.verifyHostKeys.desc': 'Ask before connecting to a new or changed SSH host key. Turn this off only for trusted private networks.',
|
||||
'settings.terminal.connection.temporaryLegacyAlgorithms': 'Allow legacy algorithms for temporary connections',
|
||||
'settings.terminal.connection.temporaryLegacyAlgorithms.desc': 'SSH connections with no saved host, including PuTTY-style launches and quick connect, offer the same deprecated algorithms as the per-host option. Saved hosts keep their own setting. Off by default; these algorithms have known weaknesses.',
|
||||
'settings.terminal.connection.temporaryNetworkDeviceMode': 'Network device mode for temporary connections',
|
||||
'settings.terminal.connection.temporaryNetworkDeviceMode.desc': 'SSH connections with no saved host, including external launches and quick connect, start in network device mode. Saved hosts and groups keep their own device type. Off by default.',
|
||||
'settings.terminal.connection.sshAutoReconnectEnabled': 'Automatically reconnect SSH sessions',
|
||||
'settings.terminal.connection.sshAutoReconnectEnabled.desc': 'When turned on, established SSH sessions that drop unexpectedly try to reconnect every 5 seconds until the tab is closed or the connection succeeds.',
|
||||
'settings.terminal.connection.keepaliveInterval': 'Keepalive Interval',
|
||||
|
||||
@@ -912,11 +912,13 @@ export const enTerminalMessages: Messages = {
|
||||
'keyboard.interactive.desc': 'The server requires additional authentication.',
|
||||
'keyboard.interactive.descWithHost': 'The server {hostname} requires additional authentication.',
|
||||
'keyboard.interactive.response': 'Response',
|
||||
'keyboard.interactive.verificationCode': 'Verification code',
|
||||
'keyboard.interactive.enterCode': 'Enter verification code',
|
||||
'keyboard.interactive.enterResponse': 'Enter response',
|
||||
'keyboard.interactive.submit': 'Submit',
|
||||
'keyboard.interactive.verifying': 'Verifying...',
|
||||
'keyboard.interactive.savePassword': 'Save password',
|
||||
'keyboard.interactive.fillTotp': 'Fill verification code',
|
||||
|
||||
// Passphrase Modal for encrypted SSH keys
|
||||
'passphrase.title': 'SSH Key Passphrase',
|
||||
|
||||
@@ -668,6 +668,10 @@ export const enVaultMessages: Messages = {
|
||||
|
||||
// Quick Switcher
|
||||
'qs.search.placeholder': 'Search hosts or tabs',
|
||||
'qs.quickConnectTo': 'Quick connect to {target}',
|
||||
'qs.hosts': 'Hosts',
|
||||
'qs.tabs': 'Tabs',
|
||||
'qs.newWorkspace': 'New Workspace',
|
||||
'qs.jumpTo': 'Jump To',
|
||||
'qs.localTerminal': 'Local Terminal',
|
||||
'qs.localShells': 'Local Shells',
|
||||
@@ -876,6 +880,35 @@ export const enVaultMessages: Messages = {
|
||||
'hostDetails.auth.certificate.desc': 'Use the selected SSH certificate, with the saved password as a fallback when present.',
|
||||
'hostDetails.auth.mfaFirst': 'Prefer interactive auth',
|
||||
'hostDetails.auth.mfaFirst.desc': 'For servers that require password and MFA through keyboard-interactive.',
|
||||
'hostDetails.totp.title': 'Authenticator (TOTP)',
|
||||
'hostDetails.totp.enable': 'Save a TOTP secret for this host',
|
||||
'hostDetails.totp.setup': 'Set up…',
|
||||
'hostDetails.totp.edit': 'Edit',
|
||||
'hostDetails.totp.modalTitle': 'Set up authenticator',
|
||||
'hostDetails.totp.replacePlaceholder': 'Paste a new secret to replace the current one',
|
||||
'hostDetails.totp.warning': 'Anyone who can open this vault gets this code and the password. Use it only on a personal device.',
|
||||
'hostDetails.totp.secret': 'Secret or otpauth URI',
|
||||
'hostDetails.totp.secretPlaceholder': 'Paste otpauth://totp/... or a Base32 secret',
|
||||
'hostDetails.totp.importQr': 'Import QR image',
|
||||
'hostDetails.totp.scanScreen': 'Scan QR code from screen',
|
||||
'hostDetails.totp.scanning': 'Scanning screens…',
|
||||
'hostDetails.totp.importedAccount': 'Imported {account}',
|
||||
'hostDetails.totp.preview': 'Current code',
|
||||
'hostDetails.totp.countdown': '{seconds}s remaining',
|
||||
'hostDetails.totp.autoSubmit': 'Auto-submit on known TOTP prompts',
|
||||
'hostDetails.totp.autoSubmitDesc': 'Submits once per connection, and only when the prompt exactly matches JumpServer, Google Authenticator PAM, or your pattern.',
|
||||
'hostDetails.totp.promptPattern': 'Custom prompt pattern',
|
||||
'hostDetails.totp.promptPatternPlaceholder': 'Optional RE2 regular expression',
|
||||
'hostDetails.totp.clear': 'Remove authenticator',
|
||||
'hostDetails.totp.removeTitle': 'Remove authenticator?',
|
||||
'hostDetails.totp.removeMessage': 'This host will no longer store this TOTP secret.',
|
||||
'hostDetails.totp.error.hotp': 'HOTP is not supported. Use a TOTP secret.',
|
||||
'hostDetails.totp.error.migration': 'otpauth-migration QR codes are not supported. Use the service binding QR code for a single account.',
|
||||
'hostDetails.totp.error.invalid': 'Could not read a TOTP secret from that value.',
|
||||
'hostDetails.totp.error.notFound': 'No TOTP QR code was found.',
|
||||
'hostDetails.totp.error.permission': 'Screen recording permission is required. Allow Netcatty to record the screen in System Settings, then try again.',
|
||||
'hostDetails.totp.error.unavailable': 'Screen capture is not available.',
|
||||
'hostDetails.totp.error.pattern': 'The custom prompt pattern is not a valid RE2 regular expression.',
|
||||
'hostDetails.auth.credentialRequired': 'Select a key or certificate before saving this login method.',
|
||||
'hostDetails.identity.suggestions': 'Identities',
|
||||
'hostDetails.identity.missing': 'Identity not found',
|
||||
|
||||
@@ -753,6 +753,8 @@ Resalta el panel dividido enfocado:
|
||||
'settings.terminal.connection.verifyHostKeys.desc': 'Pregunta antes de conectarse a una clave de host SSH nueva o modificada. Desactívalo solo en redes privadas de confianza.',
|
||||
'settings.terminal.connection.temporaryLegacyAlgorithms': 'Permitir algoritmos heredados en conexiones temporales',
|
||||
'settings.terminal.connection.temporaryLegacyAlgorithms.desc': 'Las conexiones SSH sin un host guardado, incluidos los inicios al estilo PuTTY y la conexión rápida, ofrecen los mismos algoritmos obsoletos que la opción por host. Los hosts guardados conservan su propio ajuste. Desactivado de forma predeterminada; estos algoritmos tienen debilidades conocidas.',
|
||||
'settings.terminal.connection.temporaryNetworkDeviceMode': 'Modo de dispositivo de red para conexiones temporales',
|
||||
'settings.terminal.connection.temporaryNetworkDeviceMode.desc': 'Las conexiones SSH sin un host guardado, incluidos los inicios externos y la conexión rápida, se inician en modo de dispositivo de red. Los hosts y grupos guardados conservan su propio tipo de dispositivo. Desactivado de forma predeterminada.',
|
||||
'settings.terminal.connection.sshAutoReconnectEnabled': 'Reconectar sesiones SSH automáticamente',
|
||||
'settings.terminal.connection.sshAutoReconnectEnabled.desc': 'Cuando está activado, las sesiones SSH establecidas que se caen inesperadamente intentan reconectarse cada 5 segundos hasta que la pestaña se cierra o la conexión se establece.',
|
||||
'settings.terminal.connection.keepaliveInterval': 'Intervalo de keepalive',
|
||||
|
||||
@@ -874,11 +874,13 @@ export const esTerminalMessages: Messages = {
|
||||
'keyboard.interactive.desc': 'El servidor requiere autenticación adicional.',
|
||||
'keyboard.interactive.descWithHost': 'El servidor {hostname} requiere autenticación adicional.',
|
||||
'keyboard.interactive.response': 'Respuesta',
|
||||
'keyboard.interactive.verificationCode': 'Código de verificación',
|
||||
'keyboard.interactive.enterCode': 'Ingresa el código de verificación',
|
||||
'keyboard.interactive.enterResponse': 'Ingresa la respuesta',
|
||||
'keyboard.interactive.submit': 'Enviar',
|
||||
'keyboard.interactive.verifying': 'Verificando...',
|
||||
'keyboard.interactive.savePassword': 'Guardar contraseña',
|
||||
'keyboard.interactive.fillTotp': 'Rellenar código de verificación',
|
||||
|
||||
// Passphrase Modal for encrypted SSH keys
|
||||
'passphrase.title': 'Frase de contraseña de la clave SSH',
|
||||
|
||||
@@ -668,6 +668,10 @@ export const esVaultMessages: Messages = {
|
||||
|
||||
// Quick Switcher
|
||||
'qs.search.placeholder': 'Buscar hosts o pestañas',
|
||||
'qs.quickConnectTo': 'Conexión rápida a {target}',
|
||||
'qs.hosts': 'Hosts',
|
||||
'qs.tabs': 'Pestañas',
|
||||
'qs.newWorkspace': 'Nuevo espacio de trabajo',
|
||||
'qs.jumpTo': 'Ir a',
|
||||
'qs.localTerminal': 'Terminal local',
|
||||
'qs.localShells': 'Shells locales',
|
||||
@@ -866,6 +870,35 @@ export const esVaultMessages: Messages = {
|
||||
'hostDetails.auth.certificate.desc': 'Usa el certificado SSH seleccionado, con la contraseña guardada como respaldo cuando esté presente.',
|
||||
'hostDetails.auth.mfaFirst': 'Preferir autenticación interactiva',
|
||||
'hostDetails.auth.mfaFirst.desc': 'Para servidores que requieren contraseña y MFA a través de keyboard-interactive.',
|
||||
'hostDetails.totp.title': 'Autenticador (TOTP)',
|
||||
'hostDetails.totp.enable': 'Guardar un secreto TOTP para este host',
|
||||
'hostDetails.totp.setup': 'Configurar…',
|
||||
'hostDetails.totp.edit': 'Editar',
|
||||
'hostDetails.totp.modalTitle': 'Configurar autenticador',
|
||||
'hostDetails.totp.replacePlaceholder': 'Pega un secreto nuevo para reemplazar el actual',
|
||||
'hostDetails.totp.warning': 'Quien abra esta caja fuerte obtiene este código y la contraseña. Úsalo solo en un dispositivo personal.',
|
||||
'hostDetails.totp.secret': 'Secreto o URI otpauth',
|
||||
'hostDetails.totp.secretPlaceholder': 'Pega otpauth://totp/... o un secreto Base32',
|
||||
'hostDetails.totp.importQr': 'Importar imagen QR',
|
||||
'hostDetails.totp.scanScreen': 'Escanear código QR de la pantalla',
|
||||
'hostDetails.totp.scanning': 'Escaneando pantallas…',
|
||||
'hostDetails.totp.importedAccount': 'Importado: {account}',
|
||||
'hostDetails.totp.preview': 'Código actual',
|
||||
'hostDetails.totp.countdown': 'Quedan {seconds} s',
|
||||
'hostDetails.totp.autoSubmit': 'Enviar automáticamente en avisos TOTP conocidos',
|
||||
'hostDetails.totp.autoSubmitDesc': 'Se envía una sola vez por conexión y solo si el aviso coincide exactamente con JumpServer, Google Authenticator PAM o tu patrón.',
|
||||
'hostDetails.totp.promptPattern': 'Patrón de aviso personalizado',
|
||||
'hostDetails.totp.promptPatternPlaceholder': 'Expresión regular RE2 opcional',
|
||||
'hostDetails.totp.clear': 'Quitar autenticador',
|
||||
'hostDetails.totp.removeTitle': '¿Quitar el autenticador?',
|
||||
'hostDetails.totp.removeMessage': 'Este host ya no guardará este secreto TOTP.',
|
||||
'hostDetails.totp.error.hotp': 'HOTP no es compatible. Usa un secreto TOTP.',
|
||||
'hostDetails.totp.error.migration': 'Los códigos otpauth-migration no son compatibles. Usa el código QR de vinculación de una sola cuenta.',
|
||||
'hostDetails.totp.error.invalid': 'No se pudo leer un secreto TOTP de ese valor.',
|
||||
'hostDetails.totp.error.notFound': 'No se encontró un código QR TOTP.',
|
||||
'hostDetails.totp.error.permission': 'Se necesita permiso de grabación de pantalla. Permite que Netcatty grabe la pantalla en Ajustes del sistema e inténtalo de nuevo.',
|
||||
'hostDetails.totp.error.unavailable': 'La captura de pantalla no está disponible.',
|
||||
'hostDetails.totp.error.pattern': 'El patrón personalizado no es una expresión regular RE2 válida.',
|
||||
'hostDetails.auth.credentialRequired': 'Selecciona una clave o un certificado antes de guardar este método de inicio de sesión.',
|
||||
'hostDetails.identity.suggestions': 'Identidades',
|
||||
'hostDetails.identity.missing': 'Identidad no encontrada',
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import en from './en';
|
||||
import zhCN from './zh-CN';
|
||||
import zhTW from './zh-TW';
|
||||
|
||||
const KEYS = [
|
||||
'ai.opencode.v2.startupTimeout',
|
||||
'ai.opencode.v2.exited',
|
||||
'ai.opencode.v2.unauthorized',
|
||||
'ai.opencode.v2.unavailable',
|
||||
'ai.opencode.v2.sessionFailed',
|
||||
'ai.opencode.v2.empty',
|
||||
'ai.opencode.v2.contextSkipped',
|
||||
'ai.opencode.v2.mcpSkipped',
|
||||
];
|
||||
|
||||
test('OpenCode 2 strings exist in English, Simplified Chinese, and Taiwan Chinese', () => {
|
||||
for (const key of KEYS) {
|
||||
assert.equal(typeof en[key], 'string', `en missing ${key}`);
|
||||
assert.equal(typeof zhCN[key], 'string', `zh-CN missing ${key}`);
|
||||
assert.equal(typeof zhTW[key], 'string', `zh-TW missing ${key}`);
|
||||
assert.notEqual(en[key], zhCN[key]);
|
||||
assert.notEqual(en[key], zhTW[key]);
|
||||
assert.notEqual(zhCN[key], zhTW[key]);
|
||||
}
|
||||
});
|
||||
|
||||
test('Taiwan OpenCode 2 wording uses Taiwan terms', () => {
|
||||
const text = KEYS.map((key) => zhTW[key]).join('\n');
|
||||
assert.match(text, /終端機/);
|
||||
assert.match(text, /連線/);
|
||||
assert.match(text, /設定/);
|
||||
assert.match(text, /傳回/);
|
||||
assert.equal(text.includes('终端'), false);
|
||||
assert.equal(text.includes('连接'), false);
|
||||
assert.equal(text.includes('设置'), false);
|
||||
assert.equal(text.includes('返回'), false);
|
||||
});
|
||||
@@ -793,6 +793,8 @@ export const ruCoreMessages: Messages = {
|
||||
'settings.terminal.section.connection': 'Подключение',
|
||||
'settings.terminal.connection.temporaryLegacyAlgorithms': 'Устаревшие алгоритмы для временных подключений',
|
||||
'settings.terminal.connection.temporaryLegacyAlgorithms.desc': 'SSH-подключения без сохранённого хоста, включая запуск в стиле PuTTY и быстрое подключение, предлагают те же устаревшие алгоритмы, что и настройка хоста. Сохранённые хосты используют свою настройку. По умолчанию выключено: у этих алгоритмов есть известные слабости.',
|
||||
'settings.terminal.connection.temporaryNetworkDeviceMode': 'Режим сетевого устройства для временных подключений',
|
||||
'settings.terminal.connection.temporaryNetworkDeviceMode.desc': 'SSH-подключения без сохранённого хоста, включая внешний запуск и быстрое подключение, открываются в режиме сетевого устройства. Сохранённые хосты и группы сохраняют свой тип устройства. По умолчанию выключено.',
|
||||
'settings.terminal.connection.keepaliveInterval': 'Интервал keepalive',
|
||||
'settings.terminal.connection.keepaliveInterval.desc': 'Как часто (в секундах) отправлять keepalive-пакеты на уровне SSH. Установите 0, чтобы отключить глобально. Учтите, что отдельные хосты могут переопределять это значение в своих настройках.',
|
||||
'settings.terminal.connection.keepaliveCountMax': 'Макс. число пропущенных keepalive',
|
||||
|
||||
@@ -919,11 +919,13 @@ export const ruTerminalMessages: Messages = {
|
||||
'keyboard.interactive.desc': 'Сервер требует дополнительную аутентификацию.',
|
||||
'keyboard.interactive.descWithHost': 'Сервер {hostname} требует дополнительную аутентификацию.',
|
||||
'keyboard.interactive.response': 'Ответ',
|
||||
'keyboard.interactive.verificationCode': 'Код подтверждения',
|
||||
'keyboard.interactive.enterCode': 'Введите код подтверждения',
|
||||
'keyboard.interactive.enterResponse': 'Введите ответ',
|
||||
'keyboard.interactive.submit': 'Отправить',
|
||||
'keyboard.interactive.verifying': 'Проверка...',
|
||||
'keyboard.interactive.savePassword': 'Сохранить пароль',
|
||||
'keyboard.interactive.fillTotp': 'Подставить код подтверждения',
|
||||
|
||||
// Passphrase Modal for encrypted SSH keys
|
||||
'passphrase.title': 'Парольная фраза SSH-ключа',
|
||||
|
||||
@@ -710,6 +710,10 @@ export const ruVaultMessages: Messages = {
|
||||
|
||||
// Quick Switcher
|
||||
'qs.search.placeholder': 'Поиск хостов или вкладок',
|
||||
'qs.quickConnectTo': 'Быстрое подключение к {target}',
|
||||
'qs.hosts': 'Хосты',
|
||||
'qs.tabs': 'Вкладки',
|
||||
'qs.newWorkspace': 'Новая рабочая область',
|
||||
'qs.jumpTo': 'Перейти к',
|
||||
'qs.localTerminal': 'Локальный терминал',
|
||||
'qs.localShells': 'Локальные оболочки',
|
||||
@@ -914,6 +918,35 @@ export const ruVaultMessages: Messages = {
|
||||
'hostDetails.auth.certificate.desc': 'Использовать выбранный SSH-сертификат и сохранённый пароль как запасной вариант.',
|
||||
'hostDetails.auth.mfaFirst': 'Сначала интерактивная аутентификация',
|
||||
'hostDetails.auth.mfaFirst.desc': 'Для серверов, которым нужны пароль и MFA через keyboard-interactive.',
|
||||
'hostDetails.totp.title': 'Аутентификатор (TOTP)',
|
||||
'hostDetails.totp.enable': 'Сохранить секрет TOTP для этого хоста',
|
||||
'hostDetails.totp.setup': 'Настроить…',
|
||||
'hostDetails.totp.edit': 'Изменить',
|
||||
'hostDetails.totp.modalTitle': 'Настройка аутентификатора',
|
||||
'hostDetails.totp.replacePlaceholder': 'Вставьте новый секрет, чтобы заменить текущий',
|
||||
'hostDetails.totp.warning': 'Тот, кто откроет это хранилище, получит этот код и пароль. Используйте только на личном устройстве.',
|
||||
'hostDetails.totp.secret': 'Секрет или URI otpauth',
|
||||
'hostDetails.totp.secretPlaceholder': 'Вставьте otpauth://totp/... или секрет Base32',
|
||||
'hostDetails.totp.importQr': 'Импорт изображения QR',
|
||||
'hostDetails.totp.scanScreen': 'Сканировать QR-код с экрана',
|
||||
'hostDetails.totp.scanning': 'Сканирование экранов…',
|
||||
'hostDetails.totp.importedAccount': 'Импортировано: {account}',
|
||||
'hostDetails.totp.preview': 'Текущий код',
|
||||
'hostDetails.totp.countdown': 'Осталось {seconds} с',
|
||||
'hostDetails.totp.autoSubmit': 'Автоотправка на известных запросах TOTP',
|
||||
'hostDetails.totp.autoSubmitDesc': 'Отправляется один раз за подключение и только если запрос точно совпадает с JumpServer, Google Authenticator PAM или вашим шаблоном.',
|
||||
'hostDetails.totp.promptPattern': 'Свой шаблон запроса',
|
||||
'hostDetails.totp.promptPatternPlaceholder': 'Необязательное регулярное выражение RE2',
|
||||
'hostDetails.totp.clear': 'Удалить аутентификатор',
|
||||
'hostDetails.totp.removeTitle': 'Удалить аутентификатор?',
|
||||
'hostDetails.totp.removeMessage': 'Этот хост больше не будет хранить этот секрет TOTP.',
|
||||
'hostDetails.totp.error.hotp': 'HOTP не поддерживается. Используйте секрет TOTP.',
|
||||
'hostDetails.totp.error.migration': 'Коды otpauth-migration не поддерживаются. Используйте QR-код привязки одной учетной записи.',
|
||||
'hostDetails.totp.error.invalid': 'Не удалось прочитать секрет TOTP.',
|
||||
'hostDetails.totp.error.notFound': 'QR-код TOTP не найден.',
|
||||
'hostDetails.totp.error.permission': 'Нужно разрешение на запись экрана. Разрешите Netcatty запись экрана в системных настройках и повторите попытку.',
|
||||
'hostDetails.totp.error.unavailable': 'Захват экрана недоступен.',
|
||||
'hostDetails.totp.error.pattern': 'Пользовательский шаблон не является допустимым регулярным выражением RE2.',
|
||||
'hostDetails.auth.credentialRequired': 'Перед сохранением этого способа входа выберите ключ или сертификат.',
|
||||
'hostDetails.identity.suggestions': 'Идентификаторы',
|
||||
'hostDetails.identity.missing': 'Идентификатор не найден',
|
||||
|
||||
@@ -63,6 +63,24 @@ test("localized settings include temporary-connection legacy algorithm labels",
|
||||
assert.match(zhTW["settings.terminal.connection.temporaryLegacyAlgorithms"], /臨時連線允許舊版演算法/);
|
||||
});
|
||||
|
||||
test("localized settings include temporary-connection network device mode labels", () => {
|
||||
const keys = [
|
||||
"settings.terminal.connection.temporaryNetworkDeviceMode",
|
||||
"settings.terminal.connection.temporaryNetworkDeviceMode.desc",
|
||||
];
|
||||
const locales = [{ name: "en", messages: en }, ...LOCALIZED_SETTINGS_LOCALES];
|
||||
|
||||
for (const locale of locales) {
|
||||
const missing = keys.filter((key) => !locale.messages[key]);
|
||||
assert.deepEqual(missing, [], `${locale.name} is missing temporary network device mode labels`);
|
||||
}
|
||||
|
||||
assert.match(zhCN["settings.terminal.connection.temporaryNetworkDeviceMode"], /临时连接使用网络设备模式/);
|
||||
assert.match(zhTW["settings.terminal.connection.temporaryNetworkDeviceMode"], /臨時連線使用網路裝置模式/);
|
||||
assert.match(zhTW["settings.terminal.connection.temporaryNetworkDeviceMode.desc"], /連線/);
|
||||
assert.match(zhTW["settings.terminal.connection.temporaryNetworkDeviceMode.desc"], /設定|群組|裝置/);
|
||||
});
|
||||
|
||||
test("localized settings include the global default proxy labels", () => {
|
||||
const keys = [
|
||||
"settings.terminal.connection.defaultProxy",
|
||||
|
||||
@@ -289,6 +289,14 @@ export const zhCNAiMessages: Messages = {
|
||||
'ai.opencode.customPathPlaceholder': '例如 /usr/local/bin/opencode',
|
||||
'ai.opencode.check': '检查',
|
||||
'ai.opencode.resetPath': '重置',
|
||||
'ai.opencode.v2.startupTimeout': 'OpenCode 2 未能及时就绪。请先在终端确认 `opencode serve` 可以启动,然后重试。',
|
||||
'ai.opencode.v2.exited': 'OpenCode 2 在对话开始前已退出。',
|
||||
'ai.opencode.v2.unauthorized': 'OpenCode 2 拒绝了连接。请重试这次对话。',
|
||||
'ai.opencode.v2.unavailable': '无法连接到 OpenCode 2。请确认已安装后再试。',
|
||||
'ai.opencode.v2.sessionFailed': 'OpenCode 2 未能完成这一轮对话。',
|
||||
'ai.opencode.v2.empty': 'OpenCode 2 没有返回内容。请在终端运行 `opencode` 完成登录和模型配置。',
|
||||
'ai.opencode.v2.contextSkipped': 'OpenCode 2 无法应用 Netcatty 的对话上下文。',
|
||||
'ai.opencode.v2.mcpSkipped': 'OpenCode 2 无法接入 MCP 工具,对话会继续,但没有该工具。',
|
||||
|
||||
// AI MiMo Code(小米 MiMo Code CLI,OpenCode 分支)
|
||||
'ai.mimo.title': 'MiMo Code',
|
||||
|
||||
@@ -1139,6 +1139,10 @@ Vaults 标签保持扁平,不使用这块背景。
|
||||
|
||||
// Quick Switcher
|
||||
'qs.search.placeholder': '搜索主机或标签页',
|
||||
'qs.quickConnectTo': '快速连接到 {target}',
|
||||
'qs.hosts': '主机',
|
||||
'qs.tabs': '标签页',
|
||||
'qs.newWorkspace': '新建工作区',
|
||||
'qs.jumpTo': '跳转到',
|
||||
'qs.localTerminal': '本地终端',
|
||||
'qs.localShells': '本地 Shell',
|
||||
|
||||
@@ -456,6 +456,8 @@ export const zhCNTerminalMessages: Messages = {
|
||||
'settings.terminal.section.connection': '连接',
|
||||
'settings.terminal.connection.temporaryLegacyAlgorithms': '临时连接允许旧版算法',
|
||||
'settings.terminal.connection.temporaryLegacyAlgorithms.desc': '没有对应已保存主机的 SSH 连接(包括外部程序以 PuTTY 参数启动,以及快速连接)会使用与主机「允许旧版算法」相同的已弃用算法。已保存主机仍使用自己的设置。默认关闭,这些算法有已知安全问题。',
|
||||
'settings.terminal.connection.temporaryNetworkDeviceMode': '临时连接使用网络设备模式',
|
||||
'settings.terminal.connection.temporaryNetworkDeviceMode.desc': '没有对应已保存主机的 SSH 连接(包括外部程序启动以及快速连接)会以网络设备模式连接。已保存主机和分组仍使用自己的设备类型。默认关闭。',
|
||||
'settings.terminal.connection.defaultProxy': '默认代理',
|
||||
'settings.terminal.connection.defaultProxy.desc': '主机没有单独设置、也没有从分组继承代理时使用的已保存代理。选择“无”则直接连接。',
|
||||
'settings.terminal.connection.defaultProxy.none': '无',
|
||||
@@ -1001,11 +1003,13 @@ export const zhCNTerminalMessages: Messages = {
|
||||
'keyboard.interactive.desc': '服务器需要额外的身份验证。',
|
||||
'keyboard.interactive.descWithHost': '服务器 {hostname} 需要额外的身份验证。',
|
||||
'keyboard.interactive.response': '响应',
|
||||
'keyboard.interactive.verificationCode': '验证码',
|
||||
'keyboard.interactive.enterCode': '输入验证码',
|
||||
'keyboard.interactive.enterResponse': '输入响应',
|
||||
'keyboard.interactive.submit': '提交',
|
||||
'keyboard.interactive.verifying': '验证中...',
|
||||
'keyboard.interactive.savePassword': '保存密码',
|
||||
'keyboard.interactive.fillTotp': '填入验证码',
|
||||
|
||||
// Passphrase Modal for encrypted SSH keys
|
||||
'passphrase.title': 'SSH 密钥密码',
|
||||
|
||||
@@ -290,6 +290,35 @@ export const zhCNVaultMessages: Messages = {
|
||||
'hostDetails.auth.certificate.desc': '使用选中的 SSH 证书;如果同时保存了密码,可在失败后尝试密码。',
|
||||
'hostDetails.auth.mfaFirst': '优先交互式认证',
|
||||
'hostDetails.auth.mfaFirst.desc': '适用于必须通过 keyboard-interactive 输入密码和 MFA 的服务器。',
|
||||
'hostDetails.totp.title': '验证器 (TOTP)',
|
||||
'hostDetails.totp.enable': '为这台主机保存 TOTP 密钥',
|
||||
'hostDetails.totp.setup': '设置…',
|
||||
'hostDetails.totp.edit': '编辑',
|
||||
'hostDetails.totp.modalTitle': '设置验证器',
|
||||
'hostDetails.totp.replacePlaceholder': '粘贴新密钥以替换当前密钥',
|
||||
'hostDetails.totp.warning': '能打开这个保险库的人会同时得到验证码和密码。请只在个人设备上使用。',
|
||||
'hostDetails.totp.secret': '密钥或 otpauth URI',
|
||||
'hostDetails.totp.secretPlaceholder': '粘贴 otpauth://totp/... 或 Base32 密钥',
|
||||
'hostDetails.totp.importQr': '导入二维码图片',
|
||||
'hostDetails.totp.scanScreen': '从屏幕扫描二维码',
|
||||
'hostDetails.totp.scanning': '正在扫描屏幕…',
|
||||
'hostDetails.totp.importedAccount': '已导入 {account}',
|
||||
'hostDetails.totp.preview': '当前验证码',
|
||||
'hostDetails.totp.countdown': '剩余 {seconds} 秒',
|
||||
'hostDetails.totp.autoSubmit': '在已知 TOTP 提示上自动提交',
|
||||
'hostDetails.totp.autoSubmitDesc': '每次连接只提交一次,且提示必须精确匹配 JumpServer、Google Authenticator PAM,或你填写的正则。',
|
||||
'hostDetails.totp.promptPattern': '自定义提示正则',
|
||||
'hostDetails.totp.promptPatternPlaceholder': '可选的 RE2 正则表达式',
|
||||
'hostDetails.totp.clear': '移除验证器',
|
||||
'hostDetails.totp.removeTitle': '移除验证器?',
|
||||
'hostDetails.totp.removeMessage': '这台主机将不再保存这个 TOTP 密钥。',
|
||||
'hostDetails.totp.error.hotp': '不支持 HOTP。请使用 TOTP 密钥。',
|
||||
'hostDetails.totp.error.migration': '不支持 otpauth-migration 二维码。请使用服务提供的单个账号绑定二维码。',
|
||||
'hostDetails.totp.error.invalid': '无法从该内容读取 TOTP 密钥。',
|
||||
'hostDetails.totp.error.notFound': '没有找到 TOTP 二维码。',
|
||||
'hostDetails.totp.error.permission': '需要屏幕录制权限。请在系统设置中允许 Netcatty 录制屏幕后再试。',
|
||||
'hostDetails.totp.error.unavailable': '无法使用屏幕捕获。',
|
||||
'hostDetails.totp.error.pattern': '自定义提示正则不是有效的 RE2 正则表达式。',
|
||||
'hostDetails.auth.credentialRequired': '保存这种登录方式前,请先选择密钥或证书。',
|
||||
'hostDetails.identity.suggestions': '身份',
|
||||
'hostDetails.identity.missing': '身份不存在',
|
||||
|
||||
@@ -280,6 +280,14 @@ export const zhTWAiMessages: Messages = {
|
||||
'ai.opencode.customPathPlaceholder': '例如 /usr/local/bin/opencode',
|
||||
'ai.opencode.check': '檢查',
|
||||
'ai.opencode.resetPath': '重設',
|
||||
'ai.opencode.v2.startupTimeout': 'OpenCode 2 未能及時就緒。請先在終端機確認 `opencode serve` 可以啟動,然後重試。',
|
||||
'ai.opencode.v2.exited': 'OpenCode 2 在對話開始前已結束。',
|
||||
'ai.opencode.v2.unauthorized': 'OpenCode 2 拒絕了連線。請重試這次對話。',
|
||||
'ai.opencode.v2.unavailable': '無法連線到 OpenCode 2。請確認已安裝後再試。',
|
||||
'ai.opencode.v2.sessionFailed': 'OpenCode 2 未能完成這一輪對話。',
|
||||
'ai.opencode.v2.empty': 'OpenCode 2 沒有傳回內容。請在終端機執行 `opencode` 完成登入和模型設定。',
|
||||
'ai.opencode.v2.contextSkipped': 'OpenCode 2 無法套用 Netcatty 的對話內容。',
|
||||
'ai.opencode.v2.mcpSkipped': 'OpenCode 2 無法掛接 MCP 工具,對話會繼續,但沒有該工具。',
|
||||
|
||||
// AI MiMo Code(小米 MiMo Code CLI,OpenCode 分支)
|
||||
'ai.mimo.title': 'MiMo Code',
|
||||
|
||||
@@ -1141,6 +1141,10 @@ Vaults 分頁維持扁平,不使用這塊背景。
|
||||
|
||||
// Quick Switcher
|
||||
'qs.search.placeholder': '搜尋主機或分頁',
|
||||
'qs.quickConnectTo': '快速連線到 {target}',
|
||||
'qs.hosts': '主機',
|
||||
'qs.tabs': '分頁',
|
||||
'qs.newWorkspace': '新增工作區',
|
||||
'qs.jumpTo': '跳轉到',
|
||||
'qs.localTerminal': '本機終端',
|
||||
'qs.localShells': '本機 Shell',
|
||||
|
||||
@@ -456,6 +456,8 @@ export const zhTWTerminalMessages: Messages = {
|
||||
'settings.terminal.section.connection': '連線',
|
||||
'settings.terminal.connection.temporaryLegacyAlgorithms': '臨時連線允許舊版演算法',
|
||||
'settings.terminal.connection.temporaryLegacyAlgorithms.desc': '沒有對應已儲存主機的 SSH 連線(包括外部程式以 PuTTY 參數啟動,以及快速連線)會使用與主機「允許舊版演算法」相同的已棄用演算法。已儲存主機仍使用自己的設定。預設關閉,這些演算法有已知安全問題。',
|
||||
'settings.terminal.connection.temporaryNetworkDeviceMode': '臨時連線使用網路裝置模式',
|
||||
'settings.terminal.connection.temporaryNetworkDeviceMode.desc': '沒有對應已儲存主機的 SSH 連線(包括外部程式啟動以及快速連線)會以網路裝置模式連線。已儲存主機和群組仍使用自己的裝置型別。預設關閉。',
|
||||
'settings.terminal.connection.defaultProxy': '預設代理',
|
||||
'settings.terminal.connection.defaultProxy.desc': '主機沒有單獨設定、也沒有從群組繼承代理時使用的已儲存代理。選擇「無」則直接連線。',
|
||||
'settings.terminal.connection.defaultProxy.none': '無',
|
||||
@@ -1001,11 +1003,13 @@ export const zhTWTerminalMessages: Messages = {
|
||||
'keyboard.interactive.desc': '伺服器需要額外的身分驗證。',
|
||||
'keyboard.interactive.descWithHost': '伺服器 {hostname} 需要額外的身分驗證。',
|
||||
'keyboard.interactive.response': '回應',
|
||||
'keyboard.interactive.verificationCode': '驗證碼',
|
||||
'keyboard.interactive.enterCode': '輸入驗證碼',
|
||||
'keyboard.interactive.enterResponse': '輸入回應',
|
||||
'keyboard.interactive.submit': '提交',
|
||||
'keyboard.interactive.submit': '送出',
|
||||
'keyboard.interactive.verifying': '驗證中...',
|
||||
'keyboard.interactive.savePassword': '儲存密碼',
|
||||
'keyboard.interactive.fillTotp': '填入驗證碼',
|
||||
|
||||
// Passphrase Modal for encrypted SSH keys
|
||||
'passphrase.title': 'SSH 金鑰密碼',
|
||||
|
||||
@@ -290,6 +290,35 @@ export const zhTWVaultMessages: Messages = {
|
||||
'hostDetails.auth.certificate.desc': '使用選取的 SSH 憑證;如果同時儲存了密碼,可在失敗後嘗試密碼。',
|
||||
'hostDetails.auth.mfaFirst': '優先互動式驗證',
|
||||
'hostDetails.auth.mfaFirst.desc': '適用於必須透過 keyboard-interactive 輸入密碼和 MFA 的伺服器。',
|
||||
'hostDetails.totp.title': '驗證器 (TOTP)',
|
||||
'hostDetails.totp.enable': '為這台主機儲存 TOTP 金鑰',
|
||||
'hostDetails.totp.setup': '設定…',
|
||||
'hostDetails.totp.edit': '編輯',
|
||||
'hostDetails.totp.modalTitle': '設定驗證器',
|
||||
'hostDetails.totp.replacePlaceholder': '貼上新金鑰以取代目前的金鑰',
|
||||
'hostDetails.totp.warning': '能開啟這個保險庫的人會同時取得驗證碼和密碼。請只在個人裝置上使用。',
|
||||
'hostDetails.totp.secret': '金鑰或 otpauth URI',
|
||||
'hostDetails.totp.secretPlaceholder': '貼上 otpauth://totp/... 或 Base32 金鑰',
|
||||
'hostDetails.totp.importQr': '匯入 QR 碼圖片',
|
||||
'hostDetails.totp.scanScreen': '從螢幕掃描 QR 碼',
|
||||
'hostDetails.totp.scanning': '正在掃描螢幕…',
|
||||
'hostDetails.totp.importedAccount': '已匯入 {account}',
|
||||
'hostDetails.totp.preview': '目前驗證碼',
|
||||
'hostDetails.totp.countdown': '剩餘 {seconds} 秒',
|
||||
'hostDetails.totp.autoSubmit': '在已知 TOTP 提示上自動送出',
|
||||
'hostDetails.totp.autoSubmitDesc': '每次連線只送出一次,且提示必須精確符合 JumpServer、Google Authenticator PAM,或你填寫的規則運算式。',
|
||||
'hostDetails.totp.promptPattern': '自訂提示規則',
|
||||
'hostDetails.totp.promptPatternPlaceholder': '可選的 RE2 規則運算式',
|
||||
'hostDetails.totp.clear': '移除驗證器',
|
||||
'hostDetails.totp.removeTitle': '移除驗證器?',
|
||||
'hostDetails.totp.removeMessage': '這台主機將不再保存這個 TOTP 金鑰。',
|
||||
'hostDetails.totp.error.hotp': '不支援 HOTP。請使用 TOTP 金鑰。',
|
||||
'hostDetails.totp.error.migration': '不支援 otpauth-migration QR 碼。請使用服務提供的單一帳號綁定 QR 碼。',
|
||||
'hostDetails.totp.error.invalid': '無法從該內容讀取 TOTP 金鑰。',
|
||||
'hostDetails.totp.error.notFound': '沒有找到 TOTP QR 碼。',
|
||||
'hostDetails.totp.error.permission': '需要螢幕錄製權限。請在系統設定中允許 Netcatty 錄製螢幕後再試。',
|
||||
'hostDetails.totp.error.unavailable': '無法使用螢幕擷取。',
|
||||
'hostDetails.totp.error.pattern': '自訂提示規則不是有效的 RE2 規則運算式。',
|
||||
'hostDetails.auth.credentialRequired': '儲存這種登入方式前,請先選擇金鑰或憑證。',
|
||||
'hostDetails.identity.suggestions': '身分',
|
||||
'hostDetails.identity.missing': '身分不存在',
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import {
|
||||
openQuickConnectFromInput,
|
||||
registerQuickConnectOpener,
|
||||
type QuickConnectOpenRequest,
|
||||
} from "./quickConnectBridge.ts";
|
||||
|
||||
test("openQuickConnectFromInput reuses the registered quick connect wizard", () => {
|
||||
const seen: QuickConnectOpenRequest[] = [];
|
||||
const unregister = registerQuickConnectOpener((request) => {
|
||||
seen.push(request);
|
||||
});
|
||||
|
||||
assert.equal(openQuickConnectFromInput(" root@10.1.1.1:2222 "), true);
|
||||
assert.deepEqual(seen, [{
|
||||
target: { hostname: "10.1.1.1", username: "root", port: 2222 },
|
||||
warnings: [],
|
||||
}]);
|
||||
|
||||
unregister();
|
||||
assert.equal(openQuickConnectFromInput("10.1.1.1"), false);
|
||||
});
|
||||
|
||||
test("openQuickConnectFromInput ignores text that is not an address", () => {
|
||||
const seen: QuickConnectOpenRequest[] = [];
|
||||
const unregister = registerQuickConnectOpener((request) => {
|
||||
seen.push(request);
|
||||
});
|
||||
|
||||
assert.equal(openQuickConnectFromInput("core switch"), false);
|
||||
assert.deepEqual(seen, []);
|
||||
unregister();
|
||||
});
|
||||
@@ -0,0 +1,28 @@
|
||||
import {
|
||||
parseQuickConnectInputWithWarnings,
|
||||
type QuickConnectTarget,
|
||||
} from "../domain/quickConnect";
|
||||
|
||||
export interface QuickConnectOpenRequest {
|
||||
target: QuickConnectTarget;
|
||||
warnings: string[];
|
||||
}
|
||||
|
||||
type QuickConnectOpener = (request: QuickConnectOpenRequest) => void;
|
||||
|
||||
let opener: QuickConnectOpener | null = null;
|
||||
|
||||
/** Vault owns the single QuickConnectWizard. The switcher asks it to open. */
|
||||
export function registerQuickConnectOpener(next: QuickConnectOpener): () => void {
|
||||
opener = next;
|
||||
return () => {
|
||||
if (opener === next) opener = null;
|
||||
};
|
||||
}
|
||||
|
||||
export function openQuickConnectFromInput(input: string): boolean {
|
||||
const parsed = parseQuickConnectInputWithWarnings(input);
|
||||
if (!parsed.target || !opener) return false;
|
||||
opener({ target: parsed.target, warnings: parsed.warnings });
|
||||
return true;
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import type { Host, Identity, KnownHost, SSHKey, TerminalSettings } from "../../
|
||||
import { isEncryptedCredentialPlaceholder, sanitizeCredentialValue } from "../../../domain/credentials";
|
||||
import { resolveBridgeKeyAuth, resolveBridgeSshAgentAuth, resolveHostAuth } from "../../../domain/sshAuth";
|
||||
import { resolveHostKeepalive } from "../../../domain/host";
|
||||
import { connectionTotpFields } from "../../../domain/totpConfig";
|
||||
import { resolveHostSshConnectionTimeouts } from "../../../domain/sshConnectionTimeouts";
|
||||
import {
|
||||
findIncompleteProxyIdentityId,
|
||||
@@ -121,6 +122,7 @@ export const buildSftpHostCredentials = ({
|
||||
authMethod: jumpAuth.authMethod,
|
||||
requiresMfa: !!jumpHost.requiresMfa,
|
||||
password: jumpPassword,
|
||||
...connectionTotpFields(jumpHost),
|
||||
privateKey: jumpKeyAuth.privateKey,
|
||||
certificate: jumpKey?.certificate,
|
||||
passphrase: jumpKeyAuth.passphrase,
|
||||
@@ -182,6 +184,7 @@ export const buildSftpHostCredentials = ({
|
||||
requiresMfa: !!host.requiresMfa,
|
||||
port: host.port || 22,
|
||||
password,
|
||||
...connectionTotpFields(host),
|
||||
privateKey: keyAuth.privateKey,
|
||||
certificate: key?.certificate,
|
||||
passphrase: keyAuth.passphrase,
|
||||
|
||||
@@ -2,6 +2,7 @@ import React from "react";
|
||||
import { ChevronDown, Eye, EyeOff, FileKey, FolderLock, FolderOpen, Key, KeyRound, MapPin, Plus, Shapes, Shield, Trash2, User, X } from "lucide-react";
|
||||
import type { Host } from "../types";
|
||||
import { applyHostAuthMethodSelection } from "../domain/sshAuth";
|
||||
import { HostTotpFields } from "./HostTotpFields";
|
||||
import { HostIconPicker } from "./HostIconPicker";
|
||||
import { Button } from "./ui/button";
|
||||
import { Combobox } from "./ui/combobox";
|
||||
@@ -101,6 +102,7 @@ export const HostDetailsConnectionSections: React.FC<HostDetailsConnectionSectio
|
||||
distroOptions,
|
||||
effectiveFormDistro,
|
||||
getDistroOptionLabel,
|
||||
totpFlushRef,
|
||||
}) => {
|
||||
const selectAuthMethod = (authMethod: "auto" | "password" | "key" | "certificate") => {
|
||||
setForm((previous: Host) => applyEffectiveHostAuthMethodSelection(
|
||||
@@ -739,6 +741,13 @@ export const HostDetailsConnectionSections: React.FC<HostDetailsConnectionSectio
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<HostTotpFields
|
||||
t={t}
|
||||
form={form}
|
||||
setForm={setForm}
|
||||
effectiveProtocol={effectiveProtocol}
|
||||
flushRef={totpFlushRef}
|
||||
/>
|
||||
</div>
|
||||
</HostDetailsSection>
|
||||
|
||||
|
||||
@@ -92,7 +92,7 @@ test("HostDetailsPanel labels command proxy summaries consistently", () => {
|
||||
assert.match(markup, /ProxyCommand/);
|
||||
assert.doesNotMatch(markup, /COMMAND/);
|
||||
assert.doesNotMatch(markup, /cloudflared access ssh/);
|
||||
assert.doesNotMatch(markup, /secret/);
|
||||
assert.doesNotMatch(markup, /--token secret/);
|
||||
});
|
||||
|
||||
test("HostDetailsPanel keeps explicitly cleared telnet credentials empty", () => {
|
||||
|
||||
@@ -44,6 +44,7 @@ import {
|
||||
} from "./ui/aside-panel";
|
||||
import { HostDetailsAdvancedSections } from "./HostDetailsAdvancedSections";
|
||||
import { detachEffectiveHostIdentity, HostDetailsConnectionSections } from "./HostDetailsConnectionSections";
|
||||
import type { HostTotpFlushResult } from "./HostTotpFields";
|
||||
import {
|
||||
LINUX_DISTRO_OPTION_IDS,
|
||||
parseOptionalPortInput,
|
||||
@@ -163,6 +164,8 @@ const HostDetailsPanel: React.FC<HostDetailsPanelPropsWithResize> = ({
|
||||
};
|
||||
const { checkSshAgent } = useApplicationBackend();
|
||||
const baselineSnippetsRef = useRef(snippets);
|
||||
const totpFlushRef = useRef<(() => Promise<HostTotpFlushResult>) | null>(null);
|
||||
const savingHostRef = useRef(false);
|
||||
const baselineHydratedRef = useRef(snippets.length > 0);
|
||||
const openingQueueIdsRef = useRef<string[]>([]);
|
||||
const [form, setForm] = useState<Host>(
|
||||
@@ -534,7 +537,12 @@ const HostDetailsPanel: React.FC<HostDetailsPanelPropsWithResize> = ({
|
||||
? pluginConnectionValid && Boolean(sanitizePluginConnection(form.pluginConnection, form.protocol))
|
||||
: hasHostname;
|
||||
|
||||
const handleSubmit = () => {
|
||||
const handleSubmit = async () => {
|
||||
if (savingHostRef.current) return;
|
||||
savingHostRef.current = true;
|
||||
try {
|
||||
const flushed = await totpFlushRef.current?.();
|
||||
if (flushed && !flushed.ok) return;
|
||||
const hostname = form.hostname.trim()
|
||||
|| (pluginProtocolActive ? form.label.trim() || form.pluginConnection?.providerId || "plugin" : "");
|
||||
if (!hostname || !canSaveHost) return;
|
||||
@@ -615,6 +623,10 @@ const HostDetailsPanel: React.FC<HostDetailsPanelPropsWithResize> = ({
|
||||
authPolicyVersion: 1,
|
||||
managedSourceId: finalManagedSourceId,
|
||||
};
|
||||
if (flushed?.ok) {
|
||||
if (flushed.totp) cleaned.totp = flushed.totp;
|
||||
else delete cleaned.totp;
|
||||
}
|
||||
if (pluginProtocolActive) {
|
||||
const pluginConnection = sanitizePluginConnection(cleaned.pluginConnection, cleaned.protocol);
|
||||
if (!pluginConnection) return;
|
||||
@@ -742,6 +754,9 @@ const HostDetailsPanel: React.FC<HostDetailsPanelPropsWithResize> = ({
|
||||
}
|
||||
}
|
||||
onSave(stripBuiltInConnectionFieldsForPluginHost(cleaned));
|
||||
} finally {
|
||||
savingHostRef.current = false;
|
||||
}
|
||||
};
|
||||
|
||||
const handleCreateGroup = () => {
|
||||
@@ -1137,6 +1152,7 @@ const HostDetailsPanel: React.FC<HostDetailsPanelPropsWithResize> = ({
|
||||
t={t}
|
||||
form={form}
|
||||
setForm={setForm}
|
||||
totpFlushRef={totpFlushRef}
|
||||
update={update}
|
||||
groupDefaults={effectiveGroupDefaults}
|
||||
effectiveAuthMethod={effectiveAuthMethod}
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { JSDOM } from "jsdom";
|
||||
import type { Host } from "../domain/models";
|
||||
|
||||
test("host totp setup stays on the shared setting row and opens a dialog", async () => {
|
||||
const dom = new JSDOM("<!doctype html><html><body><div id=\"root\"></div></body></html>", {
|
||||
url: "http://localhost",
|
||||
pretendToBeVisual: true,
|
||||
});
|
||||
const previous = new Map<string, PropertyDescriptor | undefined>();
|
||||
const install = (key: string, value: unknown) => {
|
||||
previous.set(key, Object.getOwnPropertyDescriptor(globalThis, key));
|
||||
Object.defineProperty(globalThis, key, { configurable: true, writable: true, value });
|
||||
};
|
||||
class ResizeObserverStub {
|
||||
observe() {}
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
}
|
||||
install("window", dom.window);
|
||||
install("document", dom.window.document);
|
||||
install("navigator", dom.window.navigator);
|
||||
install("HTMLElement", dom.window.HTMLElement);
|
||||
install("HTMLInputElement", dom.window.HTMLInputElement);
|
||||
install("HTMLButtonElement", dom.window.HTMLButtonElement);
|
||||
install("Element", dom.window.Element);
|
||||
install("Node", dom.window.Node);
|
||||
install("MutationObserver", dom.window.MutationObserver);
|
||||
install("Event", dom.window.Event);
|
||||
install("CustomEvent", dom.window.CustomEvent);
|
||||
install("MouseEvent", dom.window.MouseEvent);
|
||||
install("KeyboardEvent", dom.window.KeyboardEvent);
|
||||
install("FocusEvent", dom.window.FocusEvent);
|
||||
install("PointerEvent", dom.window.PointerEvent);
|
||||
install("getComputedStyle", dom.window.getComputedStyle.bind(dom.window));
|
||||
install("requestAnimationFrame", dom.window.requestAnimationFrame.bind(dom.window));
|
||||
install("cancelAnimationFrame", dom.window.cancelAnimationFrame.bind(dom.window));
|
||||
install("ResizeObserver", ResizeObserverStub);
|
||||
install("IS_REACT_ACT_ENVIRONMENT", true);
|
||||
Object.assign(dom.window, { netcatty: {} });
|
||||
|
||||
const { act } = await import("react");
|
||||
const { createRoot } = await import("react-dom/client");
|
||||
const { HostTotpFields } = await import("./HostTotpFields.tsx");
|
||||
const { TooltipProvider } = await import("./ui/tooltip.tsx");
|
||||
|
||||
const form = {
|
||||
id: "host-1",
|
||||
label: "Jump",
|
||||
hostname: "127.0.0.1",
|
||||
port: 22,
|
||||
username: "root",
|
||||
protocol: "ssh",
|
||||
} as Host;
|
||||
let latest = form;
|
||||
const root = createRoot(dom.window.document.getElementById("root")!);
|
||||
const t = (key: string) => key;
|
||||
try {
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<TooltipProvider>
|
||||
<HostTotpFields
|
||||
t={t}
|
||||
form={latest}
|
||||
setForm={(updater) => {
|
||||
latest = typeof updater === "function" ? updater(latest) : updater;
|
||||
}}
|
||||
effectiveProtocol="ssh"
|
||||
/>
|
||||
</TooltipProvider>,
|
||||
);
|
||||
});
|
||||
const markup = dom.window.document.body.innerHTML;
|
||||
assert.match(markup, /hostDetails\.totp\.setup/);
|
||||
assert.doesNotMatch(markup, /rounded-md border border-border\/60 p-3/);
|
||||
assert.equal(dom.window.document.querySelector("[role='dialog']"), null);
|
||||
assert.equal(dom.window.document.querySelector("[role='switch']"), null);
|
||||
|
||||
const button = [...dom.window.document.querySelectorAll("button")]
|
||||
.find((node) => node.textContent?.includes("hostDetails.totp.setup"));
|
||||
assert.ok(button);
|
||||
await act(async () => { button.click(); });
|
||||
assert.ok(dom.window.document.body.textContent?.includes("hostDetails.totp.modalTitle"));
|
||||
assert.ok(dom.window.document.body.innerHTML.includes("hostDetails.totp.secretPlaceholder"));
|
||||
assert.equal(latest.totp, undefined);
|
||||
const fields = readFileSync(new URL("./HostTotpFields.tsx", import.meta.url), "utf8");
|
||||
assert.match(fields, /label=\{t\("hostDetails\.totp\.title"\)\}/);
|
||||
assert.match(fields, /setConfirmClear\(true\)/);
|
||||
assert.match(fields, /variant="secondary"/);
|
||||
const sections = readFileSync(new URL("./HostDetailsConnectionSections.tsx", import.meta.url), "utf8");
|
||||
const passwordAt = sections.indexOf("hostDetails.password.placeholder");
|
||||
const totpAt = sections.indexOf("<HostTotpFields");
|
||||
assert.ok(passwordAt > 0 && totpAt > passwordAt);
|
||||
} finally {
|
||||
await act(async () => { root.unmount(); });
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
for (const [key, descriptor] of previous) {
|
||||
if (descriptor) Object.defineProperty(globalThis, key, descriptor);
|
||||
else delete (globalThis as Record<string, unknown>)[key];
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,568 @@
|
||||
import React, { useEffect, useRef, useState } from "react";
|
||||
import jsQR from "jsqr";
|
||||
import { RE2JS } from "re2js";
|
||||
import type { Host, HostTotpConfig } from "../domain/models";
|
||||
import { netcattyBridge } from "@/infrastructure/services/netcattyBridge.ts";
|
||||
import { Button } from "./ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "./ui/dialog";
|
||||
import { HostDetailsSettingRow } from "./host-details";
|
||||
import { SystemPanelConfirmDialog } from "./systemManager/SystemPanelConfirmDialog";
|
||||
import { Input } from "./ui/input";
|
||||
import { Label } from "./ui/label";
|
||||
import { Switch } from "./ui/switch";
|
||||
|
||||
type Preview = {
|
||||
uri: string;
|
||||
code: string;
|
||||
secondsRemaining: number;
|
||||
issuer?: string;
|
||||
label?: string;
|
||||
};
|
||||
|
||||
export type HostTotpFlushResult =
|
||||
| { ok: true; totp: HostTotpConfig | undefined }
|
||||
| { ok: false };
|
||||
|
||||
type HostTotpFieldsProps = {
|
||||
t: (key: string, params?: Record<string, string | number>) => string;
|
||||
form: Host;
|
||||
setForm: (updater: Host | ((host: Host) => Host)) => void;
|
||||
effectiveProtocol?: string;
|
||||
flushRef?: React.MutableRefObject<(() => Promise<HostTotpFlushResult>) | null>;
|
||||
};
|
||||
|
||||
const HIDDEN_PROTOCOLS = new Set(["telnet", "serial"]);
|
||||
|
||||
function errorKey(error: string | undefined): string {
|
||||
switch (error) {
|
||||
case "hotp":
|
||||
return "hostDetails.totp.error.hotp";
|
||||
case "migration":
|
||||
return "hostDetails.totp.error.migration";
|
||||
case "not-found":
|
||||
return "hostDetails.totp.error.notFound";
|
||||
case "screen-permission-denied":
|
||||
return "hostDetails.totp.error.permission";
|
||||
case "unavailable":
|
||||
return "hostDetails.totp.error.unavailable";
|
||||
case "pattern":
|
||||
return "hostDetails.totp.error.pattern";
|
||||
default:
|
||||
return "hostDetails.totp.error.invalid";
|
||||
}
|
||||
}
|
||||
|
||||
async function decodeQrFile(file: File): Promise<string> {
|
||||
const bitmap = await createImageBitmap(file);
|
||||
try {
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = bitmap.width;
|
||||
canvas.height = bitmap.height;
|
||||
const context = canvas.getContext("2d", { willReadFrequently: true });
|
||||
if (!context) throw new Error("unavailable");
|
||||
context.drawImage(bitmap, 0, 0);
|
||||
const image = context.getImageData(0, 0, canvas.width, canvas.height);
|
||||
return jsQR(image.data, image.width, image.height)?.data || "";
|
||||
} finally {
|
||||
bitmap.close?.();
|
||||
}
|
||||
}
|
||||
|
||||
function validPattern(value: string): string | null {
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) return "";
|
||||
if (trimmed.length > 200) return null;
|
||||
try {
|
||||
RE2JS.compile(trimmed);
|
||||
return trimmed;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function importedAccount(preview: Preview): string {
|
||||
const issuer = preview.issuer?.trim() || "";
|
||||
const label = preview.label?.trim() || "";
|
||||
if (issuer && label && !label.toLowerCase().startsWith(issuer.toLowerCase())) {
|
||||
return `${issuer} (${label})`;
|
||||
}
|
||||
return label || issuer;
|
||||
}
|
||||
|
||||
function useLiveTotp(source: string | undefined, hostId: string): Preview | null {
|
||||
const [preview, setPreview] = useState<Preview | null>(null);
|
||||
const hostRef = useRef(hostId);
|
||||
hostRef.current = hostId;
|
||||
|
||||
useEffect(() => {
|
||||
if (!source) {
|
||||
setPreview(null);
|
||||
return undefined;
|
||||
}
|
||||
const expectedHostId = hostId;
|
||||
let cancelled = false;
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!bridge?.previewTotp) return undefined;
|
||||
const endsAtRef = { current: 0 };
|
||||
let refreshed = false;
|
||||
const apply = (next: Preview) => {
|
||||
if (cancelled || hostRef.current !== expectedHostId) return;
|
||||
endsAtRef.current = Date.now() + next.secondsRemaining * 1000;
|
||||
refreshed = false;
|
||||
setPreview(next);
|
||||
};
|
||||
void bridge.previewTotp({ text: source }).then((result) => {
|
||||
if (!result?.ok || !result.uri || !result.code) return;
|
||||
apply({
|
||||
uri: result.uri,
|
||||
code: result.code,
|
||||
secondsRemaining: result.secondsRemaining || result.period || 30,
|
||||
issuer: result.issuer,
|
||||
label: result.label,
|
||||
});
|
||||
}).catch(() => undefined);
|
||||
const timer = setInterval(() => {
|
||||
if (cancelled || !endsAtRef.current) return;
|
||||
const left = Math.max(0, Math.ceil((endsAtRef.current - Date.now()) / 1000));
|
||||
setPreview((current) => (
|
||||
current && current.secondsRemaining !== left
|
||||
? { ...current, secondsRemaining: left }
|
||||
: current
|
||||
));
|
||||
if (left !== 0 || refreshed) return;
|
||||
refreshed = true;
|
||||
void bridge.previewTotp({ text: source }).then((result) => {
|
||||
if (!result?.ok || !result.uri || !result.code) return;
|
||||
apply({
|
||||
uri: result.uri,
|
||||
code: result.code,
|
||||
secondsRemaining: result.secondsRemaining || result.period || 30,
|
||||
issuer: result.issuer,
|
||||
label: result.label,
|
||||
});
|
||||
}).catch(() => undefined);
|
||||
}, 250);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
clearInterval(timer);
|
||||
};
|
||||
}, [hostId, source]);
|
||||
|
||||
return source ? preview : null;
|
||||
}
|
||||
|
||||
export const HostTotpFields: React.FC<HostTotpFieldsProps> = ({
|
||||
t,
|
||||
form,
|
||||
setForm,
|
||||
effectiveProtocol,
|
||||
flushRef,
|
||||
}) => {
|
||||
const configured = Boolean(form.totp?.uri);
|
||||
const [setupOpen, setSetupOpen] = useState(false);
|
||||
const [draft, setDraft] = useState("");
|
||||
const [pattern, setPattern] = useState(form.totp?.promptPattern || "");
|
||||
const [autoSubmit, setAutoSubmit] = useState(form.totp?.autoSubmit !== false);
|
||||
const [modalUri, setModalUri] = useState<string | undefined>(undefined);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [scanning, setScanning] = useState(false);
|
||||
const [confirmClear, setConfirmClear] = useState(false);
|
||||
const fileRef = useRef<HTMLInputElement>(null);
|
||||
const hostIdRef = useRef(form.id);
|
||||
const formRef = useRef(form);
|
||||
const setupOpenRef = useRef(setupOpen);
|
||||
const draftRef = useRef(draft);
|
||||
const patternRef = useRef(pattern);
|
||||
const autoSubmitRef = useRef(autoSubmit);
|
||||
const modalUriRef = useRef(modalUri);
|
||||
const requestRef = useRef(0);
|
||||
formRef.current = form;
|
||||
setupOpenRef.current = setupOpen;
|
||||
draftRef.current = draft;
|
||||
patternRef.current = pattern;
|
||||
autoSubmitRef.current = autoSubmit;
|
||||
modalUriRef.current = modalUri;
|
||||
|
||||
const [trackedHostId, setTrackedHostId] = useState(form.id);
|
||||
if (form.id !== trackedHostId) {
|
||||
setTrackedHostId(form.id);
|
||||
setSetupOpen(false);
|
||||
setDraft("");
|
||||
setPattern(form.totp?.promptPattern || "");
|
||||
setAutoSubmit(form.totp?.autoSubmit !== false);
|
||||
setModalUri(undefined);
|
||||
setError(null);
|
||||
setBusy(false);
|
||||
setScanning(false);
|
||||
setConfirmClear(false);
|
||||
draftRef.current = "";
|
||||
patternRef.current = form.totp?.promptPattern || "";
|
||||
autoSubmitRef.current = form.totp?.autoSubmit !== false;
|
||||
modalUriRef.current = undefined;
|
||||
setupOpenRef.current = false;
|
||||
}
|
||||
hostIdRef.current = form.id;
|
||||
|
||||
const rowPreview = useLiveTotp(form.totp?.uri, form.id);
|
||||
const modalPreview = useLiveTotp(setupOpen ? (modalUri || form.totp?.uri) : undefined, form.id);
|
||||
|
||||
const writeTotp = (next: HostTotpConfig | undefined) => {
|
||||
const expectedHostId = hostIdRef.current;
|
||||
setForm((current) => {
|
||||
if (current.id !== expectedHostId) return current;
|
||||
const updated = { ...current };
|
||||
if (next?.uri) updated.totp = next;
|
||||
else delete updated.totp;
|
||||
return updated;
|
||||
});
|
||||
};
|
||||
|
||||
const previewText = async (text: string): Promise<string | null> => {
|
||||
const expectedHostId = hostIdRef.current;
|
||||
const requestId = ++requestRef.current;
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!bridge?.previewTotp) {
|
||||
setError("unavailable");
|
||||
return null;
|
||||
}
|
||||
setBusy(true);
|
||||
try {
|
||||
const result = await bridge.previewTotp({ text });
|
||||
if (hostIdRef.current !== expectedHostId || requestRef.current !== requestId) return null;
|
||||
if (!result?.ok || !result.uri || !result.code) {
|
||||
setError(result?.error || "invalid");
|
||||
return null;
|
||||
}
|
||||
setError(null);
|
||||
setModalUri(result.uri);
|
||||
modalUriRef.current = result.uri;
|
||||
setDraft("");
|
||||
draftRef.current = "";
|
||||
return result.uri;
|
||||
} catch {
|
||||
if (hostIdRef.current === expectedHostId && requestRef.current === requestId) setError("unavailable");
|
||||
return null;
|
||||
} finally {
|
||||
if (hostIdRef.current === expectedHostId && requestRef.current === requestId) setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const commitSetup = async (options?: { requireSecret?: boolean }): Promise<HostTotpFlushResult> => {
|
||||
const expectedHostId = hostIdRef.current;
|
||||
const patternValue = validPattern(patternRef.current);
|
||||
if (patternValue == null) {
|
||||
setError("pattern");
|
||||
return { ok: false };
|
||||
}
|
||||
let uri = modalUriRef.current || formRef.current.totp?.uri;
|
||||
const text = draftRef.current.trim();
|
||||
if (text) {
|
||||
uri = await previewText(text) || undefined;
|
||||
if (hostIdRef.current !== expectedHostId) return { ok: false };
|
||||
if (!uri) return { ok: false };
|
||||
}
|
||||
if (!uri) {
|
||||
if (options?.requireSecret) {
|
||||
setError("invalid");
|
||||
return { ok: false };
|
||||
}
|
||||
return { ok: true, totp: formRef.current.totp };
|
||||
}
|
||||
const next: HostTotpConfig = {
|
||||
uri,
|
||||
autoSubmit: autoSubmitRef.current,
|
||||
...(patternValue ? { promptPattern: patternValue } : {}),
|
||||
};
|
||||
writeTotp(next);
|
||||
setSetupOpen(false);
|
||||
setupOpenRef.current = false;
|
||||
setDraft("");
|
||||
draftRef.current = "";
|
||||
setModalUri(undefined);
|
||||
modalUriRef.current = undefined;
|
||||
setError(null);
|
||||
return { ok: true, totp: next };
|
||||
};
|
||||
const commitRef = useRef(commitSetup);
|
||||
commitRef.current = commitSetup;
|
||||
|
||||
useEffect(() => {
|
||||
if (!flushRef) return undefined;
|
||||
const flush = async (): Promise<HostTotpFlushResult> => {
|
||||
const expectedHostId = hostIdRef.current;
|
||||
if (hostIdRef.current !== expectedHostId) return { ok: false };
|
||||
if (!setupOpenRef.current) return { ok: true, totp: formRef.current.totp };
|
||||
return commitRef.current();
|
||||
};
|
||||
flushRef.current = flush;
|
||||
return () => {
|
||||
if (flushRef.current === flush) flushRef.current = null;
|
||||
};
|
||||
}, [flushRef]);
|
||||
|
||||
const openSetup = () => {
|
||||
setPattern(form.totp?.promptPattern || "");
|
||||
patternRef.current = form.totp?.promptPattern || "";
|
||||
setAutoSubmit(form.totp?.autoSubmit !== false);
|
||||
autoSubmitRef.current = form.totp?.autoSubmit !== false;
|
||||
setDraft("");
|
||||
draftRef.current = "";
|
||||
setModalUri(undefined);
|
||||
modalUriRef.current = undefined;
|
||||
setError(null);
|
||||
setSetupOpen(true);
|
||||
setupOpenRef.current = true;
|
||||
};
|
||||
|
||||
const cancelSetup = () => {
|
||||
requestRef.current += 1;
|
||||
setSetupOpen(false);
|
||||
setupOpenRef.current = false;
|
||||
setDraft("");
|
||||
draftRef.current = "";
|
||||
setModalUri(undefined);
|
||||
modalUriRef.current = undefined;
|
||||
setError(null);
|
||||
setBusy(false);
|
||||
setScanning(false);
|
||||
};
|
||||
|
||||
const clearTotp = () => {
|
||||
cancelSetup();
|
||||
writeTotp(undefined);
|
||||
};
|
||||
|
||||
if (effectiveProtocol && HIDDEN_PROTOCOLS.has(effectiveProtocol)) return null;
|
||||
|
||||
const scanScreen = async () => {
|
||||
const expectedHostId = form.id;
|
||||
const requestId = ++requestRef.current;
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!bridge?.scanTotpQrFromScreen) {
|
||||
setError("unavailable");
|
||||
return;
|
||||
}
|
||||
setBusy(true);
|
||||
setScanning(true);
|
||||
try {
|
||||
const result = await bridge.scanTotpQrFromScreen();
|
||||
if (hostIdRef.current !== expectedHostId || requestRef.current !== requestId) return;
|
||||
if (!result?.ok || !result.uri || !result.code) {
|
||||
setError(result?.error || "not-found");
|
||||
return;
|
||||
}
|
||||
setError(null);
|
||||
setModalUri(result.uri);
|
||||
modalUriRef.current = result.uri;
|
||||
setDraft("");
|
||||
draftRef.current = "";
|
||||
} catch {
|
||||
if (hostIdRef.current === expectedHostId && requestRef.current === requestId) setError("unavailable");
|
||||
} finally {
|
||||
if (hostIdRef.current === expectedHostId && requestRef.current === requestId) {
|
||||
setBusy(false);
|
||||
setScanning(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<HostDetailsSettingRow
|
||||
label={t("hostDetails.totp.title")}
|
||||
hint={t("hostDetails.totp.warning")}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
{rowPreview && (
|
||||
<span
|
||||
className="font-mono text-xs font-normal tabular-nums tracking-widest text-muted-foreground"
|
||||
title={t("hostDetails.totp.countdown", { seconds: rowPreview.secondsRemaining })}
|
||||
>
|
||||
{rowPreview.code}
|
||||
</span>
|
||||
)}
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 px-2"
|
||||
onClick={openSetup}
|
||||
>
|
||||
{configured ? t("hostDetails.totp.edit") : t("hostDetails.totp.setup")}
|
||||
</Button>
|
||||
</div>
|
||||
</HostDetailsSettingRow>
|
||||
|
||||
<Dialog open={setupOpen} onOpenChange={(open) => { if (!open) cancelSetup(); }}>
|
||||
<DialogContent className="sm:max-w-[440px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t("hostDetails.totp.modalTitle")}</DialogTitle>
|
||||
<DialogDescription>{t("hostDetails.totp.warning")}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="host-totp-secret">{t("hostDetails.totp.secret")}</Label>
|
||||
<Input
|
||||
id="host-totp-secret"
|
||||
value={draft}
|
||||
onChange={(event) => {
|
||||
setDraft(event.target.value);
|
||||
draftRef.current = event.target.value;
|
||||
}}
|
||||
onBlur={() => {
|
||||
if (draftRef.current.trim()) void previewText(draftRef.current);
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter") {
|
||||
event.preventDefault();
|
||||
void commitSetup({ requireSecret: true });
|
||||
}
|
||||
}}
|
||||
placeholder={configured ? t("hostDetails.totp.replacePlaceholder") : t("hostDetails.totp.secretPlaceholder")}
|
||||
aria-label={t("hostDetails.totp.secret")}
|
||||
className="h-8 font-mono text-xs"
|
||||
disabled={busy}
|
||||
spellCheck={false}
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
className="h-8 focus:bg-secondary focus:text-secondary-foreground focus-visible:bg-secondary focus-visible:text-secondary-foreground"
|
||||
disabled={busy}
|
||||
onClick={() => fileRef.current?.click()}
|
||||
>
|
||||
{t("hostDetails.totp.importQr")}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
className="h-8 focus:bg-secondary focus:text-secondary-foreground focus-visible:bg-secondary focus-visible:text-secondary-foreground"
|
||||
disabled={busy}
|
||||
onClick={() => { void scanScreen(); }}
|
||||
>
|
||||
{scanning ? t("hostDetails.totp.scanning") : t("hostDetails.totp.scanScreen")}
|
||||
</Button>
|
||||
<input
|
||||
ref={fileRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
className="hidden"
|
||||
onChange={(event) => {
|
||||
const file = event.target.files?.[0];
|
||||
event.target.value = "";
|
||||
if (!file) return;
|
||||
const expectedHostId = form.id;
|
||||
const requestId = ++requestRef.current;
|
||||
setBusy(true);
|
||||
void decodeQrFile(file)
|
||||
.then((text) => {
|
||||
if (hostIdRef.current !== expectedHostId || requestRef.current !== requestId) return;
|
||||
if (!text) {
|
||||
setError("not-found");
|
||||
setBusy(false);
|
||||
return;
|
||||
}
|
||||
return previewText(text);
|
||||
})
|
||||
.catch(() => {
|
||||
if (hostIdRef.current !== expectedHostId || requestRef.current !== requestId) return;
|
||||
setError("invalid");
|
||||
setBusy(false);
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{modalPreview && (
|
||||
<div className="space-y-1">
|
||||
{importedAccount(modalPreview) ? (
|
||||
<p className="text-sm text-foreground">
|
||||
{t("hostDetails.totp.importedAccount", { account: importedAccount(modalPreview) })}
|
||||
</p>
|
||||
) : null}
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("hostDetails.totp.preview")}
|
||||
{" "}
|
||||
<span className="font-mono tracking-widest text-foreground">{modalPreview.code}</span>
|
||||
<span className="ml-2">{t("hostDetails.totp.countdown", { seconds: modalPreview.secondsRemaining })}</span>
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{error && <p className="text-xs text-destructive">{t(errorKey(error))}</p>}
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-medium text-foreground">{t("hostDetails.totp.autoSubmit")}</p>
|
||||
<p className="text-xs text-muted-foreground">{t("hostDetails.totp.autoSubmitDesc")}</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={autoSubmit}
|
||||
onCheckedChange={(checked) => {
|
||||
setAutoSubmit(checked);
|
||||
autoSubmitRef.current = checked;
|
||||
}}
|
||||
aria-label={t("hostDetails.totp.autoSubmit")}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="host-totp-pattern">{t("hostDetails.totp.promptPattern")}</Label>
|
||||
<Input
|
||||
id="host-totp-pattern"
|
||||
value={pattern}
|
||||
onChange={(event) => {
|
||||
setPattern(event.target.value);
|
||||
patternRef.current = event.target.value;
|
||||
setError((current) => (current === "pattern" ? null : current));
|
||||
}}
|
||||
placeholder={t("hostDetails.totp.promptPatternPlaceholder")}
|
||||
aria-label={t("hostDetails.totp.promptPattern")}
|
||||
className="h-8 font-mono text-xs"
|
||||
spellCheck={false}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter className="gap-2 sm:justify-between">
|
||||
{configured ? (
|
||||
<Button type="button" variant="ghost" onClick={() => setConfirmClear(true)}>
|
||||
{t("hostDetails.totp.clear")}
|
||||
</Button>
|
||||
) : <span />}
|
||||
<div className="flex gap-2">
|
||||
<Button type="button" variant="secondary" onClick={cancelSetup}>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
<Button type="button" onClick={() => { void commitSetup({ requireSecret: true }); }} disabled={busy}>
|
||||
{t("common.save")}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
{confirmClear ? (
|
||||
<SystemPanelConfirmDialog
|
||||
open
|
||||
destructive
|
||||
title={t("hostDetails.totp.removeTitle")}
|
||||
message={t("hostDetails.totp.removeMessage")}
|
||||
confirmLabel={t("hostDetails.totp.clear")}
|
||||
onOpenChange={(open) => { if (!open) setConfirmClear(false); }}
|
||||
onConfirm={() => {
|
||||
setConfirmClear(false);
|
||||
clearTotp();
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -4,48 +4,94 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { createRequire } from "node:module";
|
||||
|
||||
import { formatKeyboardInteractiveServerPrompt } from "./KeyboardInteractiveModal.tsx";
|
||||
const require = createRequire(import.meta.url);
|
||||
|
||||
import {
|
||||
keyboardInteractiveFieldLabel,
|
||||
keyboardInteractiveGuidance,
|
||||
totpFillPlacement,
|
||||
visibleTotpFillPlacements,
|
||||
} from "./KeyboardInteractiveModal.tsx";
|
||||
|
||||
const modalSource = readFileSync(new URL("./KeyboardInteractiveModal.tsx", import.meta.url), "utf8");
|
||||
|
||||
test("formatKeyboardInteractiveServerPrompt preserves server instructions and prompt labels", () => {
|
||||
const text = formatKeyboardInteractiveServerPrompt({
|
||||
hostname: "192.168.9.138",
|
||||
name: "Keyboard-interactive authentication prompts from server",
|
||||
instructions: "为保障主机安全,请输入二次认证密码,如有疑问,请联系xxx,电话xxx。",
|
||||
prompts: [
|
||||
{
|
||||
prompt: "Secondary Authentication Password:",
|
||||
echo: false,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
assert.equal(
|
||||
text,
|
||||
[
|
||||
"Keyboard-interactive authentication prompts from server:",
|
||||
"| 为保障主机安全,请输入二次认证密码,如有疑问,请联系xxx,电话xxx。",
|
||||
"| Secondary Authentication Password:",
|
||||
].join("\n"),
|
||||
);
|
||||
test("keyboardInteractiveFieldLabel uses the cleaned last prompt line", () => {
|
||||
assert.equal(keyboardInteractiveFieldLabel("Secondary Authentication Password:", "Verification code"), "Secondary Authentication Password");
|
||||
assert.equal(keyboardInteractiveFieldLabel("JumpServer:\n| SMS code:", "Verification code"), "SMS code");
|
||||
assert.equal(keyboardInteractiveFieldLabel("[OTP Code]: ", "Verification code"), "OTP Code");
|
||||
assert.equal(keyboardInteractiveFieldLabel(" ", "Verification code"), "Verification code");
|
||||
});
|
||||
|
||||
test("formatKeyboardInteractiveServerPrompt omits hostname-only fallback prompts", () => {
|
||||
const text = formatKeyboardInteractiveServerPrompt({
|
||||
hostname: "192.168.9.138",
|
||||
name: "192.168.9.138",
|
||||
instructions: "",
|
||||
prompts: [
|
||||
{
|
||||
prompt: "Password:",
|
||||
echo: false,
|
||||
},
|
||||
],
|
||||
});
|
||||
test("keyboard-interactive dialog shows a field label and an inline TOTP action", async () => {
|
||||
assert.doesNotMatch(modalSource, /<pre/);
|
||||
assert.match(modalSource, /keyboardInteractiveFieldLabel\(/);
|
||||
assert.match(modalSource, /keyboardInteractiveGuidance\(/);
|
||||
assert.match(modalSource, /data-totp-fill=\{fillPlacement\}/);
|
||||
assert.match(modalSource, /variant="secondary"/);
|
||||
assert.doesNotMatch(modalSource, /variant=\{fillPlacement/);
|
||||
assert.match(modalSource, /totpPrompt\.shared\.mjs/);
|
||||
const shared = require("../domain/totpPrompt.shared.cjs") as { NON_TOTP_PROMPT: RegExp };
|
||||
const runtime = require("../domain/totp.shared.cjs") as { NON_TOTP_PROMPT: RegExp };
|
||||
const esm = await import("../domain/totpPrompt.shared.mjs");
|
||||
assert.equal(shared.NON_TOTP_PROMPT, runtime.NON_TOTP_PROMPT);
|
||||
assert.equal(esm.NON_TOTP_PROMPT.source, shared.NON_TOTP_PROMPT.source);
|
||||
assert.doesNotMatch(modalSource, /ring-primary/);
|
||||
assert.doesNotMatch(modalSource, /className="w-full"/);
|
||||
assert.doesNotMatch(modalSource, /onTypeTotp/);
|
||||
});
|
||||
|
||||
assert.equal(text, "");
|
||||
test("keyboardInteractiveGuidance keeps directions that are not already the field label", () => {
|
||||
assert.equal(keyboardInteractiveGuidance({
|
||||
name: "bastion",
|
||||
hostname: "bastion",
|
||||
instructions: "",
|
||||
prompts: [{ prompt: "Verification code:", echo: false }],
|
||||
}), "");
|
||||
assert.equal(keyboardInteractiveGuidance({
|
||||
name: "Duo Security",
|
||||
hostname: "vpn.example",
|
||||
instructions: "1. Duo Push\n2. SMS\nPasscode or option (1-3):",
|
||||
prompts: [{ prompt: "Passcode or option (1-3):", echo: true }],
|
||||
}), "Duo Security\n1. Duo Push\n2. SMS");
|
||||
assert.equal(keyboardInteractiveGuidance({
|
||||
name: "host",
|
||||
hostname: "other",
|
||||
instructions: "| Verification code:",
|
||||
prompts: [{ prompt: "Verification code:", echo: false }],
|
||||
}), "host");
|
||||
assert.equal(keyboardInteractiveGuidance({
|
||||
name: "bastion",
|
||||
hostname: "bastion",
|
||||
instructions: "",
|
||||
prompts: [{ prompt: "1. OTP\n2. SMS\nOption> ", echo: true }],
|
||||
}), "1. OTP\n2. SMS");
|
||||
assert.equal(keyboardInteractiveGuidance({
|
||||
name: "bastion",
|
||||
hostname: "bastion",
|
||||
instructions: "1. OTP",
|
||||
prompts: [{ prompt: "1. OTP\nOption>", echo: true }],
|
||||
}), "1. OTP");
|
||||
});
|
||||
|
||||
test("TOTP fill stays off password prompts and prefers verification prompts", () => {
|
||||
assert.equal(totpFillPlacement({ prompt: "Password:", echo: false }), "hidden");
|
||||
assert.equal(totpFillPlacement({ prompt: "Verification code:", echo: false }), "preferred");
|
||||
assert.equal(totpFillPlacement({ prompt: "[OTP Code]:", echo: false }), "preferred");
|
||||
assert.equal(totpFillPlacement({ prompt: "One-time password:", echo: false }), "preferred");
|
||||
assert.equal(totpFillPlacement({ prompt: "SMS code:", echo: false }), "hidden");
|
||||
assert.equal(totpFillPlacement({ prompt: "1. OTP\n2. SMS\nOption> ", echo: true }), "hidden");
|
||||
assert.equal(totpFillPlacement({ prompt: "[Y/n]", echo: true }), "hidden");
|
||||
assert.equal(totpFillPlacement({ prompt: "1. OTP\nVerification code:", echo: false }), "preferred");
|
||||
assert.equal(totpFillPlacement({ prompt: "Response:", echo: true }), "available");
|
||||
assert.deepEqual(visibleTotpFillPlacements([
|
||||
{ prompt: "Password:", echo: false },
|
||||
{ prompt: "Verification code:", echo: false },
|
||||
]), ["hidden", "preferred"]);
|
||||
assert.deepEqual(visibleTotpFillPlacements([
|
||||
{ prompt: "Password:", echo: false },
|
||||
]), ["hidden"]);
|
||||
});
|
||||
|
||||
test("keyboard-interactive modal cannot be dismissed by outside click or Escape", () => {
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
*/
|
||||
import { Eye, EyeOff, KeyRound, Loader2 } from "lucide-react";
|
||||
import React, { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { NON_TOTP_PROMPT } from "../domain/totpPrompt.shared.mjs";
|
||||
import { useI18n } from "../application/i18n/I18nProvider";
|
||||
import { Button } from "./ui/button";
|
||||
import {
|
||||
@@ -34,35 +35,54 @@ export interface KeyboardInteractiveRequest {
|
||||
savedPassword?: string | null;
|
||||
/** When false, hide save-password UI (second-factor / EDR challenges). Default true. */
|
||||
allowSavePassword?: boolean;
|
||||
/** otpauth URI from the connection attempt that opened this dialog. */
|
||||
totpUri?: string;
|
||||
}
|
||||
|
||||
type KeyboardInteractiveServerPromptInput = Pick<
|
||||
KeyboardInteractiveRequest,
|
||||
"name" | "instructions" | "prompts" | "hostname"
|
||||
>;
|
||||
/** Last meaningful line of a server prompt, without the raw banner. */
|
||||
export function keyboardInteractiveFieldLabel(prompt: string | undefined, fallback: string): string {
|
||||
const lines = String(prompt || "").split(/\r?\n/).map((line) => line.trim()).filter(Boolean);
|
||||
const last = lines.at(-1) || "";
|
||||
const cleaned = last
|
||||
.replace(/^[\s||>•*-]+/, "")
|
||||
.replace(/[::]\s*$/, "")
|
||||
.trim()
|
||||
.replace(/^\[(.+)\]$/, "$1")
|
||||
.trim();
|
||||
return cleaned || fallback;
|
||||
}
|
||||
|
||||
/** Formats the server-supplied keyboard-interactive prompt block for display. */
|
||||
export function formatKeyboardInteractiveServerPrompt(request: KeyboardInteractiveServerPromptInput): string {
|
||||
const lines: string[] = [];
|
||||
const name = request.name?.trim();
|
||||
const hostname = request.hostname?.trim();
|
||||
const instructions = request.instructions?.trim();
|
||||
const hasServerText = !!instructions || !!(name && name !== hostname);
|
||||
if (!hasServerText) return "";
|
||||
if (name && name !== hostname) {
|
||||
lines.push(name.endsWith(":") ? name : `${name}:`);
|
||||
}
|
||||
if (instructions) {
|
||||
for (const line of instructions.split(/\r?\n/).map((part) => part.trim()).filter(Boolean)) {
|
||||
lines.push(`| ${line}`);
|
||||
}
|
||||
}
|
||||
function guidanceKeys(text: string): string[] {
|
||||
const trimmed = text.trim().toLowerCase();
|
||||
const label = keyboardInteractiveFieldLabel(text, "").toLowerCase();
|
||||
return [trimmed, label].filter(Boolean);
|
||||
}
|
||||
|
||||
/** Server directions that are not already the field label. Plain text, not a prompt dump. */
|
||||
export function keyboardInteractiveGuidance(request: Pick<KeyboardInteractiveRequest, "name" | "instructions" | "prompts" | "hostname">): string {
|
||||
const labelKeys = new Set<string>();
|
||||
const promptLines: string[] = [];
|
||||
for (const prompt of request.prompts || []) {
|
||||
const promptText = prompt.prompt?.trim();
|
||||
if (promptText) {
|
||||
lines.push(`| ${promptText}`);
|
||||
}
|
||||
const parts = String(prompt?.prompt || "").split(/\r?\n/).map((line) => line.trim()).filter(Boolean);
|
||||
const last = parts.at(-1) || "";
|
||||
for (const key of guidanceKeys(last)) labelKeys.add(key);
|
||||
promptLines.push(...parts.slice(0, -1));
|
||||
}
|
||||
const seen = new Set<string>();
|
||||
const lines: string[] = [];
|
||||
const push = (text: string) => {
|
||||
const line = text.trim().replace(/^\|\s*/, "");
|
||||
if (!line) return;
|
||||
const keys = guidanceKeys(line);
|
||||
if (keys.some((key) => labelKeys.has(key) || seen.has(key))) return;
|
||||
for (const key of keys) seen.add(key);
|
||||
lines.push(line);
|
||||
};
|
||||
const hostname = (request.hostname || "").trim().toLowerCase();
|
||||
const name = (request.name || "").trim();
|
||||
if (name && name.toLowerCase() !== hostname) push(name);
|
||||
for (const part of String(request.instructions || "").split(/\r?\n/)) push(part);
|
||||
for (const part of promptLines) push(part);
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
@@ -107,12 +127,43 @@ interface KeyboardInteractiveModalProps {
|
||||
request: KeyboardInteractiveRequest | null;
|
||||
onSubmit: (requestId: string, responses: string[], savePassword?: string) => boolean | Promise<boolean>;
|
||||
onCancel: (requestId: string) => boolean | Promise<boolean>;
|
||||
canFillTotp?: boolean;
|
||||
onFillTotp?: () => Promise<string | null> | string | null;
|
||||
}
|
||||
|
||||
const OTP_PROMPT = /otp|one[-\s]?time|totp|verification|passcode|authenticator|\bmfa\b|\b2fa\b|two[-\s]?factor|验证码|驗證碼|动态口令|動態口令|二次认证|二次認證|二次验证|二次驗證/i;
|
||||
|
||||
export type TotpFillPlacement = "preferred" | "available" | "hidden";
|
||||
|
||||
function lastPromptLine(prompt: string | undefined): string {
|
||||
const lines = String(prompt || "").split(/\r?\n/).map((line) => line.trim()).filter(Boolean);
|
||||
return lines.at(-1) || "";
|
||||
}
|
||||
|
||||
/** Where a TOTP fill action may appear. Password prompts never receive the code. */
|
||||
export function totpFillPlacement(prompt: KeyboardInteractivePrompt): TotpFillPlacement {
|
||||
const rawLast = lastPromptLine(prompt.prompt);
|
||||
const label = keyboardInteractiveFieldLabel(prompt.prompt, "");
|
||||
if (!label || NON_TOTP_PROMPT.test(rawLast) || NON_TOTP_PROMPT.test(label)) return "hidden";
|
||||
if (OTP_PROMPT.test(label)) return "preferred";
|
||||
if (isAPasswordPrompt({ prompt: label, echo: prompt.echo })) return "hidden";
|
||||
if (/\b(password|passwd|passphrase)\b/i.test(label)) return "hidden";
|
||||
return "available";
|
||||
}
|
||||
|
||||
export function visibleTotpFillPlacements(prompts: KeyboardInteractivePrompt[]): TotpFillPlacement[] {
|
||||
const placements = prompts.map(totpFillPlacement);
|
||||
const hasPreferred = placements.some((placement) => placement === "preferred");
|
||||
if (!hasPreferred) return placements;
|
||||
return placements.map((placement) => (placement === "preferred" ? placement : "hidden"));
|
||||
}
|
||||
|
||||
export const KeyboardInteractiveModal: React.FC<KeyboardInteractiveModalProps> = ({
|
||||
request,
|
||||
onSubmit,
|
||||
onCancel,
|
||||
canFillTotp = false,
|
||||
onFillTotp,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const [responses, setResponses] = useState<string[]>([]);
|
||||
@@ -185,6 +236,15 @@ export const KeyboardInteractiveModal: React.FC<KeyboardInteractiveModalProps> =
|
||||
}
|
||||
}, [request, onCancel, isSubmitting]);
|
||||
|
||||
const handleFillTotp = useCallback(async (index: number) => {
|
||||
if (!request || isSubmitting || !onFillTotp) return;
|
||||
const prompt = request.prompts[index];
|
||||
if (!prompt || totpFillPlacement(prompt) === "hidden") return;
|
||||
const code = await onFillTotp();
|
||||
if (!code) return;
|
||||
handleResponseChange(index, code);
|
||||
}, [request, isSubmitting, onFillTotp, handleResponseChange]);
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent) => {
|
||||
if (e.key === "Enter" && !isSubmitting) {
|
||||
@@ -197,12 +257,14 @@ export const KeyboardInteractiveModal: React.FC<KeyboardInteractiveModalProps> =
|
||||
|
||||
if (!request) return null;
|
||||
|
||||
const serverPromptText = formatKeyboardInteractiveServerPrompt(request);
|
||||
const title = t("keyboard.interactive.title");
|
||||
const description =
|
||||
request.hostname
|
||||
? t("keyboard.interactive.descWithHost", { hostname: request.hostname })
|
||||
: t("keyboard.interactive.desc");
|
||||
const fillPlacements = canFillTotp ? visibleTotpFillPlacements(request.prompts) : [];
|
||||
const preferredFillIndex = fillPlacements.findIndex((placement) => placement === "preferred");
|
||||
const guidance = keyboardInteractiveGuidance(request);
|
||||
|
||||
return (
|
||||
<Dialog open={!!request} onOpenChange={() => {/* intentionally non-dismissable */}}>
|
||||
@@ -227,26 +289,40 @@ export const KeyboardInteractiveModal: React.FC<KeyboardInteractiveModalProps> =
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4 py-2">
|
||||
{serverPromptText && (
|
||||
<pre className="max-h-36 overflow-auto rounded-md border border-border/60 bg-muted/45 px-3 py-2 text-xs leading-relaxed text-foreground whitespace-pre-wrap [overflow-wrap:anywhere]">
|
||||
{serverPromptText}
|
||||
</pre>
|
||||
)}
|
||||
|
||||
{guidance ? (
|
||||
<p className="max-h-36 overflow-auto whitespace-pre-wrap text-sm leading-relaxed text-muted-foreground [overflow-wrap:anywhere]">
|
||||
{guidance}
|
||||
</p>
|
||||
) : null}
|
||||
{request.prompts.map((prompt, index) => {
|
||||
const isPassword = !prompt.echo;
|
||||
const showPassword = showPasswords[index];
|
||||
const showPromptLabel = !(serverPromptText && request.prompts.length === 1);
|
||||
// Clean up prompt text (remove trailing colon and whitespace)
|
||||
const promptLabel = prompt.prompt.replace(/:\s*$/, "").trim();
|
||||
const promptLabel = keyboardInteractiveFieldLabel(
|
||||
prompt.prompt,
|
||||
t("keyboard.interactive.verificationCode"),
|
||||
);
|
||||
const fillPlacement = fillPlacements[index] ?? "hidden";
|
||||
|
||||
return (
|
||||
<div key={index} className="space-y-2">
|
||||
{showPromptLabel && (
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<Label htmlFor={`ki-prompt-${index}`}>
|
||||
{promptLabel || t("keyboard.interactive.response")}
|
||||
{promptLabel}
|
||||
</Label>
|
||||
)}
|
||||
{fillPlacement !== "hidden" && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
className="h-7 shrink-0 px-2 font-normal focus:bg-secondary focus:text-secondary-foreground focus-visible:bg-secondary focus-visible:text-secondary-foreground"
|
||||
data-totp-fill={fillPlacement}
|
||||
onClick={() => { void handleFillTotp(index); }}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
{t("keyboard.interactive.fillTotp")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<div className="relative">
|
||||
<Input
|
||||
id={`ki-prompt-${index}`}
|
||||
@@ -256,7 +332,7 @@ export const KeyboardInteractiveModal: React.FC<KeyboardInteractiveModalProps> =
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder=""
|
||||
className={isPassword ? "pr-10" : undefined}
|
||||
autoFocus={index === 0}
|
||||
autoFocus={preferredFillIndex >= 0 ? index === preferredFillIndex : index === 0}
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
{isPassword && (
|
||||
@@ -288,7 +364,7 @@ export const KeyboardInteractiveModal: React.FC<KeyboardInteractiveModalProps> =
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between pt-2">
|
||||
<Button
|
||||
|
||||
@@ -4,6 +4,7 @@ import { applyGroupDefaults, resolveGroupDefaults } from "../domain/groupConfig"
|
||||
import { sanitizeCredentialValue } from "../domain/credentials";
|
||||
import { hasBridgeSshCredentials, resolveBridgeKeyAuth, resolveBridgeSshAgentAuth, resolveHostAuth } from "../domain/sshAuth";
|
||||
import { resolveHostSshConnectionTimeouts } from "../domain/sshConnectionTimeouts";
|
||||
import { connectionTotpFields } from "../domain/totpConfig";
|
||||
import { cn } from "../lib/utils";
|
||||
import { Button } from "./ui/button";
|
||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "./ui/collapsible";
|
||||
@@ -258,6 +259,7 @@ export const KeychainExportPanel: React.FC<KeychainExportPanelProps> = ({
|
||||
sshAuthReadyTimeoutMs: connectionTimeouts.authReadyTimeoutSeconds * 1000,
|
||||
enableKeyboardInteractive: true,
|
||||
requiresMfa: !!effectiveExportHost.requiresMfa,
|
||||
...connectionTotpFields(effectiveExportHost),
|
||||
sessionId: `export-key:${effectiveExportHost.id}:${panel.key.id}`,
|
||||
});
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
User,
|
||||
} from "lucide-react";
|
||||
import React, { useMemo, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { useI18n } from "../application/i18n/I18nProvider";
|
||||
import type { QuickConnectTarget } from "../domain/quickConnect";
|
||||
import { formatHostPort } from "../domain/host";
|
||||
@@ -671,7 +672,7 @@ const QuickConnectWizard: React.FC<QuickConnectWizardProps> = ({
|
||||
: `${protocolLabel} ${hostEndpoint}`
|
||||
);
|
||||
|
||||
return (
|
||||
const wizard = (
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-center justify-center"
|
||||
onClick={onClose}
|
||||
@@ -798,6 +799,13 @@ const QuickConnectWizard: React.FC<QuickConnectWizardProps> = ({
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
// The vault surface is visibility:hidden while a terminal tab is active.
|
||||
// Portal so the same wizard opened from the tab-bar switcher stays visible.
|
||||
if (typeof document !== "undefined" && document.body) {
|
||||
return createPortal(wizard, document.body);
|
||||
}
|
||||
return wizard;
|
||||
};
|
||||
|
||||
export default QuickConnectWizard;
|
||||
@@ -0,0 +1,163 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import React from "react";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
|
||||
import { I18nProvider } from "../application/i18n/I18nProvider";
|
||||
import type { Host, TerminalSession } from "../types";
|
||||
import { QuickSwitcher } from "./QuickSwitcher";
|
||||
|
||||
const host = (overrides: Partial<Host> = {}): Host => ({
|
||||
id: "saved-1",
|
||||
label: "core-switch",
|
||||
hostname: "10.1.1.1",
|
||||
username: "admin",
|
||||
tags: [],
|
||||
os: "linux",
|
||||
protocol: "ssh",
|
||||
...overrides,
|
||||
}) as Host;
|
||||
|
||||
const renderSwitcher = (
|
||||
query: string,
|
||||
results: Host[] = [],
|
||||
locale = "en",
|
||||
) => renderToStaticMarkup(
|
||||
<I18nProvider locale={locale}>
|
||||
<QuickSwitcher
|
||||
isOpen
|
||||
query={query}
|
||||
results={results}
|
||||
sessions={[]}
|
||||
workspaces={[]}
|
||||
onQueryChange={() => undefined}
|
||||
onSelect={() => undefined}
|
||||
onSelectTab={() => undefined}
|
||||
onClose={() => undefined}
|
||||
onQuickConnect={() => undefined}
|
||||
showSftpTab={false}
|
||||
/>
|
||||
</I18nProvider>,
|
||||
);
|
||||
|
||||
test("quick switcher keeps a matching host ahead of an address row", () => {
|
||||
const markup = renderSwitcher("10.1.1.1", [host()]);
|
||||
const quickConnectAt = markup.indexOf("Quick connect to 10.1.1.1");
|
||||
const hostAt = markup.indexOf("core-switch");
|
||||
const selectedAt = markup.indexOf("bg-primary/15");
|
||||
|
||||
assert.ok(selectedAt >= 0);
|
||||
assert.ok(hostAt > selectedAt);
|
||||
assert.ok(quickConnectAt > hostAt);
|
||||
assert.equal(markup.indexOf("bg-primary/15", selectedAt + 1), -1);
|
||||
});
|
||||
|
||||
test("quick switcher does not steal Enter from a saved host label", () => {
|
||||
for (const label of ["lab", "web01", "prod", "lab-switch"]) {
|
||||
const markup = renderSwitcher(label, [host({ id: label, label, hostname: "10.0.0.5" })]);
|
||||
const hostAt = markup.indexOf(`>${label}<`);
|
||||
const selectedAt = markup.indexOf("bg-primary/15");
|
||||
|
||||
assert.equal(markup.includes("Quick connect to"), false, label);
|
||||
assert.ok(selectedAt >= 0 && hostAt > selectedAt, label);
|
||||
}
|
||||
});
|
||||
|
||||
test("quick switcher keeps a matching open session ahead of an address row", () => {
|
||||
const markup = renderToStaticMarkup(
|
||||
<I18nProvider locale="en">
|
||||
<QuickSwitcher
|
||||
isOpen
|
||||
query="10.4.4.4"
|
||||
results={[]}
|
||||
sessions={[{
|
||||
id: "sess-1",
|
||||
hostId: "host-1",
|
||||
hostLabel: "open-core",
|
||||
username: "admin",
|
||||
hostname: "10.4.4.4",
|
||||
status: "connected",
|
||||
} as TerminalSession]}
|
||||
workspaces={[]}
|
||||
onQueryChange={() => undefined}
|
||||
onSelect={() => undefined}
|
||||
onSelectTab={() => undefined}
|
||||
onClose={() => undefined}
|
||||
onQuickConnect={() => undefined}
|
||||
showSftpTab={false}
|
||||
/>
|
||||
</I18nProvider>,
|
||||
);
|
||||
const sessionAt = markup.indexOf("open-core");
|
||||
const quickConnectAt = markup.indexOf("Quick connect to 10.4.4.4");
|
||||
const selectedAt = markup.indexOf("bg-primary/15");
|
||||
|
||||
assert.ok(selectedAt >= 0);
|
||||
assert.ok(sessionAt > selectedAt);
|
||||
assert.ok(quickConnectAt > sessionAt);
|
||||
assert.equal(markup.indexOf("bg-primary/15", selectedAt + 1), -1);
|
||||
});
|
||||
|
||||
test("quick switcher offers quick connect when nothing else matches", () => {
|
||||
const markup = renderSwitcher("root@10.2.0.8:2222");
|
||||
|
||||
assert.match(markup, /Quick connect to root@10\.2\.0\.8:2222/);
|
||||
assert.match(markup, /bg-primary\/15[\s\S]*Quick connect to root@10\.2\.0\.8:2222/);
|
||||
});
|
||||
|
||||
test("quick switcher does not offer quick connect for ordinary search text", () => {
|
||||
const markup = renderSwitcher("core switch");
|
||||
|
||||
assert.doesNotMatch(markup, /Quick connect to/);
|
||||
});
|
||||
|
||||
test("quick switcher quick connect row uses zh-TW wording", () => {
|
||||
const markup = renderToStaticMarkup(
|
||||
<I18nProvider locale="zh-TW">
|
||||
<QuickSwitcher
|
||||
isOpen
|
||||
query="10.0.0.8"
|
||||
results={[host()]}
|
||||
sessions={[]}
|
||||
workspaces={[]}
|
||||
onQueryChange={() => undefined}
|
||||
onSelect={() => undefined}
|
||||
onSelectTab={() => undefined}
|
||||
onClose={() => undefined}
|
||||
onQuickConnect={() => undefined}
|
||||
onCreateWorkspace={() => undefined}
|
||||
showSftpTab={false}
|
||||
/>
|
||||
</I18nProvider>,
|
||||
);
|
||||
|
||||
assert.match(markup, /快速連線到 10\.0\.0\.8/);
|
||||
assert.match(markup, />主機</);
|
||||
assert.match(markup, />新增工作區</);
|
||||
assert.doesNotMatch(markup, /New Workspace/);
|
||||
});
|
||||
|
||||
test("quick switcher section labels use zh-CN wording", () => {
|
||||
const markup = renderToStaticMarkup(
|
||||
<I18nProvider locale="zh-CN">
|
||||
<QuickSwitcher
|
||||
isOpen
|
||||
query=""
|
||||
results={[host()]}
|
||||
sessions={[]}
|
||||
workspaces={[]}
|
||||
onQueryChange={() => undefined}
|
||||
onSelect={() => undefined}
|
||||
onSelectTab={() => undefined}
|
||||
onClose={() => undefined}
|
||||
onQuickConnect={() => undefined}
|
||||
onCreateWorkspace={() => undefined}
|
||||
showSftpTab={false}
|
||||
/>
|
||||
</I18nProvider>,
|
||||
);
|
||||
|
||||
assert.match(markup, />主机</);
|
||||
assert.match(markup, />标签页</);
|
||||
assert.match(markup, />新建工作区</);
|
||||
});
|
||||
@@ -14,6 +14,7 @@ import { useI18n } from "../application/i18n/I18nProvider";
|
||||
import { Host, TerminalSession, TerminalSettings, Workspace } from "../types";
|
||||
import { KeyBinding } from "../domain/models";
|
||||
import type { PaneMagnificationState } from "../domain/paneMagnification";
|
||||
import { isSwitcherQuickConnectInput } from "../domain/quickConnect";
|
||||
import { matchesSearchQuery } from "../lib/searchMatcher";
|
||||
import { buildQuickSwitcherShells, useDiscoveredShells, getShellIconPath, isMonochromeShellIcon } from "../lib/useDiscoveredShells";
|
||||
import { usePluginContributions } from "../application/state/usePluginContributions";
|
||||
@@ -30,11 +31,12 @@ type QuickSwitcherItemBase = {
|
||||
shortcut?: string;
|
||||
pluginId?: string;
|
||||
icon?: NetcattyPluginIconReference;
|
||||
quickConnectInput?: string;
|
||||
};
|
||||
|
||||
type QuickSwitcherItem = QuickSwitcherItemBase & (
|
||||
| { type: "plugin-command"; commandId: string }
|
||||
| { type: "host" | "tab" | "workspace" | "action" | "shell" | "plugin-view"; commandId?: never }
|
||||
| { type: "host" | "tab" | "workspace" | "action" | "shell" | "plugin-view" | "quick-connect"; commandId?: never }
|
||||
);
|
||||
|
||||
export function buildPaneMagnificationPaletteAction(
|
||||
@@ -165,6 +167,7 @@ interface QuickSwitcherProps {
|
||||
paneMagnificationState?: PaneMagnificationState;
|
||||
onMagnifyCurrentPane?: () => void;
|
||||
onRestoreMagnifiedPane?: () => void;
|
||||
onQuickConnect?: (input: string) => void;
|
||||
}
|
||||
|
||||
const QuickSwitcherInner: React.FC<QuickSwitcherProps> = ({
|
||||
@@ -186,6 +189,7 @@ const QuickSwitcherInner: React.FC<QuickSwitcherProps> = ({
|
||||
paneMagnificationState = 'unavailable',
|
||||
onMagnifyCurrentPane,
|
||||
onRestoreMagnifiedPane,
|
||||
onQuickConnect,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const discoveredShells = useDiscoveredShells();
|
||||
@@ -335,19 +339,37 @@ const QuickSwitcherInner: React.FC<QuickSwitcherProps> = ({
|
||||
});
|
||||
};
|
||||
|
||||
if (results.length > 0) {
|
||||
pushHeader("header:hosts", "Hosts");
|
||||
results.forEach((host) =>
|
||||
pushItem({ type: "host", id: host.id, data: host }),
|
||||
);
|
||||
}
|
||||
const quickConnectItem: QuickSwitcherItem | null =
|
||||
trimmedQuery && isSwitcherQuickConnectInput(trimmedQuery)
|
||||
? {
|
||||
type: "quick-connect",
|
||||
id: "quick-connect",
|
||||
title: t("qs.quickConnectTo", { target: trimmedQuery }),
|
||||
quickConnectInput: trimmedQuery,
|
||||
}
|
||||
: null;
|
||||
|
||||
const hasTabsSection =
|
||||
builtInTabs.length > 0
|
||||
|| filteredOrphanSessions.length > 0
|
||||
|| filteredWorkspaces.length > 0;
|
||||
|
||||
// The address row is the default Enter target only when nothing else matches.
|
||||
// Matching hosts and open tabs stay ahead of it.
|
||||
if (quickConnectItem && results.length === 0 && !hasTabsSection) {
|
||||
pushItem(quickConnectItem);
|
||||
}
|
||||
|
||||
if (results.length > 0) {
|
||||
pushHeader("header:hosts", t("qs.hosts"));
|
||||
results.forEach((host) =>
|
||||
pushItem({ type: "host", id: host.id, data: host }),
|
||||
);
|
||||
if (quickConnectItem) pushItem(quickConnectItem);
|
||||
}
|
||||
|
||||
if (hasTabsSection) {
|
||||
pushHeader("header:tabs", "Tabs");
|
||||
pushHeader("header:tabs", t("qs.tabs"));
|
||||
builtInTabs.forEach((tabId) => {
|
||||
pushItem({ type: "tab", id: tabId });
|
||||
});
|
||||
@@ -357,6 +379,7 @@ const QuickSwitcherInner: React.FC<QuickSwitcherProps> = ({
|
||||
filteredOrphanSessions.forEach((s) =>
|
||||
pushItem({ type: "tab", id: s.id, data: s }),
|
||||
);
|
||||
if (quickConnectItem && results.length === 0) pushItem(quickConnectItem);
|
||||
}
|
||||
|
||||
if (filteredShells.length > 0) {
|
||||
@@ -445,6 +468,10 @@ const QuickSwitcherInner: React.FC<QuickSwitcherProps> = ({
|
||||
|
||||
const handleItemSelect = (item: QuickSwitcherItem, useAlternate = false) => {
|
||||
switch (item.type) {
|
||||
case "quick-connect":
|
||||
if (item.quickConnectInput) onQuickConnect?.(item.quickConnectInput);
|
||||
onClose();
|
||||
break;
|
||||
case "host":
|
||||
onSelect(item.data as Host);
|
||||
break;
|
||||
@@ -542,7 +569,7 @@ const QuickSwitcherInner: React.FC<QuickSwitcherProps> = ({
|
||||
className="ml-auto inline-flex items-center gap-1 text-[11px] text-muted-foreground hover:text-foreground border border-border rounded px-1.5 py-0.5 transition-colors hover:bg-muted/50"
|
||||
>
|
||||
<Plus size={11} />
|
||||
<span>New Workspace</span>
|
||||
<span>{t("qs.newWorkspace")}</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
@@ -576,6 +603,20 @@ const QuickSwitcherInner: React.FC<QuickSwitcherProps> = ({
|
||||
// Fixed virtual slots: keep content single-line (or plugin two-line) and clip overflow.
|
||||
const rowClass = `flex h-full min-h-0 items-center gap-3 overflow-hidden px-4 py-2.5 cursor-pointer transition-colors ${getQuickSwitcherRowStateClass(isSelected, isKeyboardNavigating)}`;
|
||||
|
||||
if (item.type === "quick-connect") {
|
||||
return (
|
||||
<div
|
||||
className={rowClass}
|
||||
onClick={() => handleItemSelect(item)}
|
||||
>
|
||||
<div className="flex h-6 w-6 shrink-0 items-center justify-center rounded text-muted-foreground">
|
||||
<Terminal size={16} />
|
||||
</div>
|
||||
<span className="min-w-0 truncate text-sm font-medium">{item.title}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (item.type === "host") {
|
||||
const host = item.data as Host;
|
||||
const hostRow = (
|
||||
|
||||
+15
-7
@@ -59,7 +59,7 @@ import { MULTILINE_PASTE_CONFIRM_MIN_LINES_DEFAULT } from "../domain/terminalPas
|
||||
import { isPluginHostProtocol } from "../domain/pluginConnection";
|
||||
import { clearTerminalBootEpoch, setTerminalBootEpoch } from "../domain/terminalBootEpoch";
|
||||
import {
|
||||
appendTerminalPromptSecurityTail,
|
||||
foldTerminalPromptSecurityOutput,
|
||||
isConfirmedTerminalShellPrompt,
|
||||
isUntrustedTerminalInputPrompt,
|
||||
} from "../domain/terminalPromptSecurity";
|
||||
@@ -452,6 +452,8 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
), [sessionId]);
|
||||
useEffect(() => () => clearTerminalBroadcastUserInput(sessionId), [sessionId]);
|
||||
const sensitivePromptOutputTailRef = useRef("");
|
||||
const heredocPromptOriginRef = useRef("");
|
||||
const promptSecurityPendingCrRef = useRef(false);
|
||||
const [activeScriptRun, setActiveScriptRun] = useState<import('@/types/global/netcatty-bridge-script.d.ts').ScriptRun | undefined>(undefined);
|
||||
const dismissedScriptRunIdsRef = useRef(new Set<string>());
|
||||
|
||||
@@ -2011,10 +2013,14 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
chunk: string,
|
||||
meta?: TerminalSessionDataMeta,
|
||||
) => {
|
||||
sensitivePromptOutputTailRef.current = appendTerminalPromptSecurityTail(
|
||||
sensitivePromptOutputTailRef.current,
|
||||
chunk,
|
||||
);
|
||||
const folded = foldTerminalPromptSecurityOutput({
|
||||
origin: heredocPromptOriginRef.current,
|
||||
current: sensitivePromptOutputTailRef.current,
|
||||
pendingCr: promptSecurityPendingCrRef.current,
|
||||
}, chunk);
|
||||
heredocPromptOriginRef.current = folded.origin;
|
||||
sensitivePromptOutputTailRef.current = folded.current;
|
||||
promptSecurityPendingCrRef.current = folded.pendingCr;
|
||||
const promptSecurityOptions = {
|
||||
allowHostStyleGreaterThan: isNetworkDevice,
|
||||
alternateScreen: termRef.current
|
||||
@@ -2030,17 +2036,19 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
autocompleteCloseRef.current?.();
|
||||
} else {
|
||||
sensitivePromptOutputTailRef.current = "";
|
||||
heredocPromptOriginRef.current = "";
|
||||
promptSecurityPendingCrRef.current = false;
|
||||
}
|
||||
return;
|
||||
} else if (isUntrustedTerminalInputPrompt(
|
||||
sensitivePromptOutputTailRef.current,
|
||||
folded.promptText,
|
||||
promptSecurityOptions,
|
||||
)) {
|
||||
passwordPromptActiveRef.current = true;
|
||||
passwordPromptActiveRef.source = "heuristic";
|
||||
autocompleteCloseRef.current?.();
|
||||
} else if (isConfirmedTerminalShellPrompt(
|
||||
sensitivePromptOutputTailRef.current,
|
||||
folded.promptText,
|
||||
promptSecurityOptions,
|
||||
)) {
|
||||
passwordPromptActiveRef.current = false;
|
||||
|
||||
@@ -44,6 +44,7 @@ import React, {
|
||||
useState,
|
||||
} from "react";
|
||||
import { useI18n } from "../application/i18n/I18nProvider";
|
||||
import { registerQuickConnectOpener } from "../application/quickConnectBridge";
|
||||
import { useStoredViewMode } from "../application/state/useStoredViewMode";
|
||||
import { useStoredBoolean } from "../application/state/useStoredBoolean";
|
||||
import { useStoredNumber } from "../application/state/useStoredNumber";
|
||||
@@ -561,6 +562,12 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
|
||||
[],
|
||||
);
|
||||
|
||||
useEffect(() => registerQuickConnectOpener(({ target, warnings }) => {
|
||||
setQuickConnectTarget(target);
|
||||
setQuickConnectWarnings(warnings);
|
||||
setIsQuickConnectOpen(true);
|
||||
}), []);
|
||||
|
||||
// Protocol select state (for hosts with multiple protocols)
|
||||
const [protocolSelectHost, setProtocolSelectHost] = useState<Host | null>(
|
||||
null,
|
||||
@@ -1792,7 +1799,8 @@ export const vaultViewAreEqual = (
|
||||
// Only these terminal connection settings are forwarded to
|
||||
// PortForwarding inside the vault, so compare them directly. Other
|
||||
// terminal settings (fonts, themes, etc.) don't affect this subtree
|
||||
// and we don't want to re-render for them.
|
||||
// and we don't want to re-render for them. onConnect is also omitted:
|
||||
// temporary-connection toggles are read from a ref at connect time.
|
||||
prev.terminalSettings?.verifyHostKeys ===
|
||||
next.terminalSettings?.verifyHostKeys &&
|
||||
prev.terminalSettings?.keepaliveInterval ===
|
||||
|
||||
@@ -51,3 +51,17 @@ test("Vault import entry is disabled while an import is running", () => {
|
||||
assert.match(vaultViewLayoutSource, /disabled=\{importProgress\?\.status === "running"\}/);
|
||||
assert.match(vaultViewLayoutSource, /if \(importProgress\?\.status === "running"\) return;/);
|
||||
});
|
||||
|
||||
test("new host menu keeps the local shell icon at the same size as its siblings", () => {
|
||||
const menuStart = vaultViewLayoutSource.indexOf('<DropdownContent className="flex w-max min-w-44 flex-col" align="end" alignToParent>');
|
||||
assert.ok(menuStart >= 0);
|
||||
const menuEnd = vaultViewLayoutSource.indexOf("</DropdownContent>", menuStart);
|
||||
const menu = vaultViewLayoutSource.slice(menuStart, menuEnd);
|
||||
assert.match(menu, /className="flex w-max min-w-44 flex-col"/);
|
||||
assert.doesNotMatch(menu, /className="w-44"/);
|
||||
assert.match(menu, /<TerminalSquare size=\{14\} className="shrink-0" \/>/);
|
||||
assert.match(menu, /<FolderTree size=\{14\} className="shrink-0" \/>/);
|
||||
assert.match(menu, /<Upload size=\{14\} className="shrink-0" \/>/);
|
||||
assert.match(menu, /<Download size=\{14\} className="shrink-0" \/>/);
|
||||
assert.doesNotMatch(menu, /className="w-full justify-start gap-2"/);
|
||||
});
|
||||
@@ -1008,6 +1008,16 @@ function SettingsTerminalTab(props: {
|
||||
onChange={(v) => updateTerminalSetting("temporaryConnectionsAllowLegacyAlgorithms", v)}
|
||||
/>
|
||||
</SettingRow>
|
||||
<SettingRow
|
||||
anchorId="terminal-temporary-network-device-mode"
|
||||
label={t("settings.terminal.connection.temporaryNetworkDeviceMode")}
|
||||
description={t("settings.terminal.connection.temporaryNetworkDeviceMode.desc")}
|
||||
>
|
||||
<Toggle
|
||||
checked={terminalSettings.temporaryConnectionsNetworkDeviceMode}
|
||||
onChange={(v) => updateTerminalSetting("temporaryConnectionsNetworkDeviceMode", v)}
|
||||
/>
|
||||
</SettingRow>
|
||||
<SettingRow
|
||||
anchorId="terminal-ssh-auto-reconnect"
|
||||
label={t("settings.terminal.connection.sshAutoReconnectEnabled")}
|
||||
|
||||
@@ -37,6 +37,7 @@ import {
|
||||
sanitizeCredentialValue,
|
||||
} from "../../../domain/credentials";
|
||||
import { resolveBridgeSshAgentAuth, resolveHostAuth } from "../../../domain/sshAuth";
|
||||
import { connectionTotpFields } from "../../../domain/totpConfig";
|
||||
import { requiresFidoSshAgentAuth } from "../../../domain/fidoSsh";
|
||||
import {
|
||||
hostRestrictsExtraSshChannels,
|
||||
@@ -478,6 +479,7 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
|
||||
authMethod: jumpAuth.authMethod,
|
||||
requiresMfa: !!jumpHost.requiresMfa,
|
||||
password: jumpPassword,
|
||||
...connectionTotpFields(jumpHost),
|
||||
privateKey: resolveBridgePrivateKeyMaterial(jumpKey, {
|
||||
forceAgent: jumpAgentAuth.useSshAgent === true && !jumpKey?.certificate,
|
||||
}),
|
||||
@@ -705,6 +707,7 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
|
||||
requiresMfa: !!ctx.host.requiresMfa,
|
||||
port: ctx.host.port || 22,
|
||||
password: attempt.password,
|
||||
...connectionTotpFields(ctx.host),
|
||||
sftpReuseOptions: sftpReuseOptions?.password || sftpReuseOptions?.username !== effectiveUsername
|
||||
? undefined
|
||||
: sftpReuseOptions,
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import test from "node:test";
|
||||
|
||||
const toastSource = readFileSync(new URL("./toast.tsx", import.meta.url), "utf8");
|
||||
|
||||
test("toast surfaces follow app theme tokens instead of OS palette fills", () => {
|
||||
const styles = toastSource.slice(
|
||||
toastSource.indexOf("const TOAST_STYLES"),
|
||||
toastSource.indexOf("export const ToastProvider"),
|
||||
);
|
||||
|
||||
assert.match(styles, /success: 'border-emerald-600'/);
|
||||
assert.match(styles, /error: 'border-red-600'/);
|
||||
assert.match(styles, /warning: 'border-yellow-600'/);
|
||||
assert.match(styles, /info: 'border-blue-600'/);
|
||||
// `dark:` is prefers-color-scheme, so these fills stay pale while
|
||||
// text-foreground / text-muted-foreground follow the `.dark` class.
|
||||
assert.doesNotMatch(styles, /bg-(?:emerald|red|yellow|blue)-50/);
|
||||
assert.doesNotMatch(styles, /dark:bg-(?:emerald|red|yellow|blue)-950/);
|
||||
assert.match(toastSource, /"bg-card animate-in/);
|
||||
assert.match(toastSource, /text-sm font-medium text-foreground/);
|
||||
assert.match(toastSource, /text-sm text-muted-foreground break-words/);
|
||||
});
|
||||
@@ -82,11 +82,15 @@ const TOAST_ICONS: Record<ToastType, React.ReactNode> = {
|
||||
info: <Info className="h-4 w-4 text-blue-500" />,
|
||||
};
|
||||
|
||||
// Colored `bg-*-50` / `dark:bg-*-950` fills follow the OS color scheme.
|
||||
// Title and body use `.dark` tokens (`text-foreground`, `text-muted-foreground`),
|
||||
// so an app-dark / OS-light session paints light text on a pale fill.
|
||||
// The container's `bg-card` tracks the app theme; the border and icon mark the variant.
|
||||
const TOAST_STYLES: Record<ToastType, string> = {
|
||||
success: 'border-emerald-600 bg-emerald-50 dark:bg-emerald-950',
|
||||
error: 'border-red-600 bg-red-50 dark:bg-red-950',
|
||||
warning: 'border-yellow-600 bg-yellow-50 dark:bg-yellow-950',
|
||||
info: 'border-blue-600 bg-blue-50 dark:bg-blue-950',
|
||||
success: 'border-emerald-600',
|
||||
error: 'border-red-600',
|
||||
warning: 'border-yellow-600',
|
||||
info: 'border-blue-600',
|
||||
};
|
||||
|
||||
export const ToastProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
||||
|
||||
@@ -1007,42 +1007,43 @@ export function VaultViewLayout({ ctx }: { ctx: VaultViewLayoutContext }) {
|
||||
</Button>
|
||||
</DropdownTrigger>
|
||||
</div>
|
||||
<DropdownContent className="w-44" align="end" alignToParent>
|
||||
{/* Fixed w-44 flex-shrinks the shell icon when the label is longer than the siblings. */}
|
||||
<DropdownContent className="flex w-max min-w-44 flex-col" align="end" alignToParent>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="w-full justify-start gap-2"
|
||||
className="justify-start gap-2"
|
||||
onClick={handleNewLocalShellHost}
|
||||
>
|
||||
<TerminalSquare size={14} /> {t("vault.hosts.newLocalShell")}
|
||||
<TerminalSquare size={14} className="shrink-0" /> {t("vault.hosts.newLocalShell")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="w-full justify-start gap-2"
|
||||
className="justify-start gap-2"
|
||||
onClick={() => {
|
||||
setTargetParentPath(selectedGroupPath);
|
||||
setNewFolderName("");
|
||||
setIsNewFolderOpen(true);
|
||||
}}
|
||||
>
|
||||
<FolderTree size={14} /> {t("vault.hosts.newGroup")}
|
||||
<FolderTree size={14} className="shrink-0" /> {t("vault.hosts.newGroup")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="w-full justify-start gap-2"
|
||||
className="justify-start gap-2"
|
||||
disabled={importProgress?.status === "running"}
|
||||
onClick={() => {
|
||||
if (importProgress?.status === "running") return;
|
||||
setIsImportOpen(true);
|
||||
}}
|
||||
>
|
||||
<Upload size={14} /> {t("vault.hosts.import")}
|
||||
<Upload size={14} className="shrink-0" /> {t("vault.hosts.import")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="w-full justify-start gap-2"
|
||||
className="justify-start gap-2"
|
||||
onClick={handleExportHosts}
|
||||
>
|
||||
<Download size={14} /> {t("vault.hosts.export")}
|
||||
<Download size={14} className="shrink-0" /> {t("vault.hosts.export")}
|
||||
</Button>
|
||||
</DropdownContent>
|
||||
</Dropdown>
|
||||
|
||||
@@ -109,6 +109,29 @@ test("stripSyncPayloadEncryptedCredentials clears device-bound placeholders for
|
||||
assert.equal(findSyncPayloadEncryptedCredentialPaths(stripped).length, 0);
|
||||
});
|
||||
|
||||
test("TOTP secrets follow the same encrypted sync recovery path as passwords", () => {
|
||||
const withTotp = samplePayload({
|
||||
hosts: [{
|
||||
...samplePayload().hosts[0]!,
|
||||
totp: { uri: ENC, autoSubmit: true },
|
||||
}],
|
||||
});
|
||||
assert.deepEqual(findSyncPayloadEncryptedCredentialPaths(withTotp), [
|
||||
"hosts[0].password",
|
||||
"hosts[0].totp.uri",
|
||||
"keys[0].privateKey",
|
||||
]);
|
||||
const stripped = stripSyncPayloadEncryptedCredentials(withTotp);
|
||||
assert.equal(stripped.hosts[0]?.totp, undefined);
|
||||
const healed = healPoisonedSecretsForMerge(withTotp, samplePayload({
|
||||
hosts: [{
|
||||
...samplePayload().hosts[0]!,
|
||||
totp: { uri: "otpauth://totp/Example:alice?secret=GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ" },
|
||||
}],
|
||||
}), null);
|
||||
assert.match(healed.hosts[0]?.totp?.uri || "", /^otpauth:\/\/totp\//);
|
||||
});
|
||||
|
||||
test("healPoisonedSecretsForMerge keeps usable preferred passwords over poisoned enc:v1", () => {
|
||||
const poisoned = samplePayload();
|
||||
const preferred = samplePayload({
|
||||
|
||||
@@ -151,6 +151,9 @@ export const findSyncPayloadEncryptedCredentialPaths = (
|
||||
if (isEncryptedCredentialPlaceholder(host.proxyConfig?.password)) {
|
||||
issues.push(`hosts[${index}].proxyConfig.password`);
|
||||
}
|
||||
if (isEncryptedCredentialPlaceholder(host.totp?.uri)) {
|
||||
issues.push(`hosts[${index}].totp.uri`);
|
||||
}
|
||||
});
|
||||
|
||||
payload.keys.forEach((key, index) => {
|
||||
@@ -208,6 +211,9 @@ export const stripSyncPayloadEncryptedCredentials = (
|
||||
const { password: _removed, ...proxyRest } = next.proxyConfig;
|
||||
next.proxyConfig = proxyRest;
|
||||
}
|
||||
if (next.totp && isEncryptedCredentialPlaceholder(next.totp.uri)) {
|
||||
delete next.totp;
|
||||
}
|
||||
return next;
|
||||
});
|
||||
|
||||
@@ -330,6 +336,15 @@ export const healPoisonedSecretsForMerge = (
|
||||
next.proxyConfig = proxyRest;
|
||||
}
|
||||
}
|
||||
if (next.totp && isEncryptedCredentialPlaceholder(next.totp.uri)) {
|
||||
const healed = healPoisonedCredential(
|
||||
preferredHost,
|
||||
preferredHost?.totp?.uri,
|
||||
fallbackHost?.totp?.uri,
|
||||
);
|
||||
if (healed !== undefined) next.totp = { ...next.totp, uri: healed };
|
||||
else delete next.totp;
|
||||
}
|
||||
return next;
|
||||
});
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
resolveProxyConfigAuth,
|
||||
} from "./proxyProfiles";
|
||||
import { isEncryptedCredentialPlaceholder, sanitizeCredentialValue } from "./credentials";
|
||||
import { connectionTotpFields } from "./totpConfig";
|
||||
import { resolveEffectiveTerminalHost, resolveTerminalChainHosts } from "./terminalHostResolution";
|
||||
|
||||
/** Test-mode connection timeouts — a probe should fail fast, not wait out the
|
||||
@@ -158,6 +159,7 @@ const buildTestJumpHost = (
|
||||
authMethod: jumpAuth.authMethod,
|
||||
requiresMfa: !!chainHost.requiresMfa,
|
||||
password: sanitizeCredentialValue(jumpAuth.password),
|
||||
...connectionTotpFields(chainHost),
|
||||
privateKey: jumpKey?.source === "reference" ? undefined : sanitizeCredentialValue(jumpKey?.privateKey),
|
||||
certificate: jumpKey?.certificate,
|
||||
passphrase: sanitizeCredentialValue(jumpAuth.passphrase || jumpKey?.passphrase),
|
||||
@@ -308,6 +310,7 @@ export const buildHostConnectionTestPlan = (
|
||||
requiresMfa: !!effectiveHost.requiresMfa,
|
||||
port: effectiveHost.port || 22,
|
||||
password: effectivePassword,
|
||||
...connectionTotpFields(effectiveHost),
|
||||
privateKey: key?.source === "reference" ? undefined : sanitizeCredentialValue(key?.privateKey),
|
||||
certificate: key?.certificate,
|
||||
publicKey: key?.publicKey,
|
||||
|
||||
@@ -12,6 +12,19 @@ export interface ConnectionLegacyAlgorithmInput {
|
||||
temporaryConnectionsAllowLegacyAlgorithms?: boolean;
|
||||
}
|
||||
|
||||
export interface PrepareConnectionHostOptions {
|
||||
/**
|
||||
* Global opt-in for SSH connections with no saved host.
|
||||
* Missing or any non-true value stays off.
|
||||
*/
|
||||
temporaryConnectionsAllowLegacyAlgorithms?: boolean;
|
||||
/**
|
||||
* Global opt-in that starts SSH connections with no saved host in network
|
||||
* device mode. Missing or any non-true value leaves deviceType alone.
|
||||
*/
|
||||
temporaryConnectionsNetworkDeviceMode?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Saved hosts keep their own legacy-algorithm choice. Connections with no
|
||||
* saved host use the global temporary-connection opt-in, which defaults to
|
||||
@@ -39,20 +52,61 @@ function isTemporarySshHost(
|
||||
return host.protocol === undefined || host.protocol === "ssh";
|
||||
}
|
||||
|
||||
function readPrepareConnectionHostOptions(
|
||||
options?: boolean | PrepareConnectionHostOptions,
|
||||
): PrepareConnectionHostOptions {
|
||||
if (typeof options === "boolean") {
|
||||
return { temporaryConnectionsAllowLegacyAlgorithms: options };
|
||||
}
|
||||
return options ?? {};
|
||||
}
|
||||
|
||||
/** Read the two temporary-connection toggles from the settings object given now. */
|
||||
export function temporaryConnectionPrepareOptions(
|
||||
settings: {
|
||||
temporaryConnectionsAllowLegacyAlgorithms?: boolean;
|
||||
temporaryConnectionsNetworkDeviceMode?: boolean;
|
||||
} | null | undefined,
|
||||
): PrepareConnectionHostOptions {
|
||||
return {
|
||||
temporaryConnectionsAllowLegacyAlgorithms:
|
||||
settings?.temporaryConnectionsAllowLegacyAlgorithms,
|
||||
temporaryConnectionsNetworkDeviceMode:
|
||||
settings?.temporaryConnectionsNetworkDeviceMode,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Stamp the resolved legacy-algorithm flag onto unsaved temporary SSH hosts.
|
||||
* Stamp temporary-connection defaults onto unsaved temporary SSH hosts.
|
||||
* Saved hosts, and ephemeral clones of saved hosts, are returned unchanged so
|
||||
* their own value (including a later group default) still applies.
|
||||
* their own values (including a later group default) still apply.
|
||||
*
|
||||
* A boolean second argument is the legacy-algorithm opt-in, matching callers
|
||||
* from before network device mode existed.
|
||||
*/
|
||||
export function prepareConnectionHost<T extends Host>(
|
||||
host: T,
|
||||
temporaryConnectionsAllowLegacyAlgorithms?: boolean,
|
||||
options?: boolean | PrepareConnectionHostOptions,
|
||||
): T {
|
||||
if (!isTemporarySshHost(host)) return host;
|
||||
const resolved = readPrepareConnectionHostOptions(options);
|
||||
const legacyAlgorithms = resolveConnectionLegacyAlgorithms({
|
||||
hasSavedHost: false,
|
||||
temporaryConnectionsAllowLegacyAlgorithms,
|
||||
temporaryConnectionsAllowLegacyAlgorithms: resolved.temporaryConnectionsAllowLegacyAlgorithms,
|
||||
});
|
||||
if ((host.legacyAlgorithms === true) === legacyAlgorithms) return host;
|
||||
return { ...host, legacyAlgorithms };
|
||||
const legacyUnchanged = (host.legacyAlgorithms === true) === legacyAlgorithms;
|
||||
// Mosh and ET are stored as SSH plus a transport flag. Network device mode
|
||||
// is a plain-SSH CLI behavior; stamping it onto those hosts makes later
|
||||
// raw-host checks (such as the copy-session cwd probe) treat them as
|
||||
// network gear.
|
||||
const stampNetworkDevice = resolved.temporaryConnectionsNetworkDeviceMode === true
|
||||
&& host.moshEnabled !== true
|
||||
&& host.etEnabled !== true;
|
||||
const deviceTypeUnchanged = !stampNetworkDevice || host.deviceType === "network";
|
||||
if (legacyUnchanged && deviceTypeUnchanged) return host;
|
||||
return {
|
||||
...host,
|
||||
...(legacyUnchanged ? {} : { legacyAlgorithms }),
|
||||
...(stampNetworkDevice ? { deviceType: "network" as const } : {}),
|
||||
};
|
||||
}
|
||||
@@ -157,6 +157,15 @@ export interface SftpBookmark {
|
||||
|
||||
export type HostAuthMethod = 'auto' | 'password' | 'key' | 'certificate';
|
||||
|
||||
/** Per-host TOTP authenticator. `uri` is an otpauth://totp URI and holds the secret. */
|
||||
export interface HostTotpConfig {
|
||||
uri: string;
|
||||
/** Defaults to true. False keeps the dialog fill button and skips auto-submit. */
|
||||
autoSubmit?: boolean;
|
||||
/** Extra RE2 regular expression matched against the KI prompt. */
|
||||
promptPattern?: string;
|
||||
}
|
||||
|
||||
export type HostOperatingSystem = 'linux' | 'windows' | 'macos' | 'freebsd' | 'unknown';
|
||||
export type HostOsSelection = 'auto' | HostOperatingSystem;
|
||||
|
||||
@@ -198,6 +207,9 @@ export interface Host {
|
||||
authPolicyVersion?: 1;
|
||||
// Prefer keyboard-interactive before the password method for MFA/PAM hosts.
|
||||
requiresMfa?: boolean;
|
||||
// Optional per-host TOTP authenticator. The secret lives in `uri` and is
|
||||
// encrypted and synced with the same path as `password`.
|
||||
totp?: HostTotpConfig;
|
||||
// Use the local SSH agent for login. This is separate from agentForwarding,
|
||||
// which exposes the local agent to the remote host after login.
|
||||
useSshAgent?: boolean;
|
||||
|
||||
@@ -149,6 +149,12 @@ export interface TerminalSettings {
|
||||
* this and keep their own setting. Off by default.
|
||||
*/
|
||||
temporaryConnectionsAllowLegacyAlgorithms: boolean;
|
||||
/**
|
||||
* Start SSH connections that have no saved host (external launches, deep
|
||||
* links, and quick connect) in network device mode. Saved hosts and groups
|
||||
* keep their own device type. Off by default.
|
||||
*/
|
||||
temporaryConnectionsNetworkDeviceMode: boolean;
|
||||
keepaliveInterval: number; // Seconds between SSH-level keepalive packets (0 = disabled)
|
||||
keepaliveCountMax: number; // Unanswered keepalives before declaring the connection dead
|
||||
sshAutoReconnectEnabled: boolean; // Automatically reconnect SSH sessions after unexpected disconnects
|
||||
@@ -476,6 +482,8 @@ export const normalizeTerminalSettings = (
|
||||
...mergedSettings,
|
||||
temporaryConnectionsAllowLegacyAlgorithms:
|
||||
mergedSettings.temporaryConnectionsAllowLegacyAlgorithms === true,
|
||||
temporaryConnectionsNetworkDeviceMode:
|
||||
mergedSettings.temporaryConnectionsNetworkDeviceMode === true,
|
||||
rendererType,
|
||||
autocompleteMaxSuggestions,
|
||||
multilinePasteConfirmMinLines: normalizeMultilinePasteConfirmMinLines(
|
||||
@@ -577,6 +585,7 @@ const DEFAULT_TERMINAL_SETTINGS: TerminalSettings = {
|
||||
// own per-host keepaliveOverride and dial these values down.
|
||||
verifyHostKeys: true,
|
||||
temporaryConnectionsAllowLegacyAlgorithms: false,
|
||||
temporaryConnectionsNetworkDeviceMode: false,
|
||||
keepaliveInterval: 30,
|
||||
keepaliveCountMax: 10,
|
||||
// On by default: unexpected transport drops (VPN / Wi-Fi blips) schedule an
|
||||
|
||||
@@ -6,7 +6,9 @@ import {
|
||||
isSafePluginAuthenticationUrl,
|
||||
pluginProtocolForProvider,
|
||||
sanitizePluginConnection,
|
||||
stripBuiltInConnectionFieldsForPluginHost,
|
||||
} from './pluginConnection.ts';
|
||||
import type { Host } from './models';
|
||||
|
||||
test('plugin connection profiles preserve opaque configuration when their provider is absent', () => {
|
||||
const providerId = 'com.example.transport.connection';
|
||||
@@ -59,6 +61,30 @@ test('plugin credentials are selectable only after secure catalog publication',
|
||||
);
|
||||
});
|
||||
|
||||
test('plugin hosts drop a built-in TOTP secret the plugin UI cannot manage', () => {
|
||||
const totp = { uri: 'otpauth://totp/Example:root?secret=GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ' };
|
||||
const base = {
|
||||
id: 'host-1',
|
||||
label: 'Host',
|
||||
hostname: 'host.test',
|
||||
username: 'root',
|
||||
tags: [],
|
||||
os: 'linux',
|
||||
password: 'secret',
|
||||
requiresMfa: true,
|
||||
totp,
|
||||
} as Host;
|
||||
const pluginHost = stripBuiltInConnectionFieldsForPluginHost({
|
||||
...base,
|
||||
protocol: pluginProtocolForProvider('com.example.transport.connection'),
|
||||
});
|
||||
assert.equal(pluginHost.totp, undefined);
|
||||
assert.equal(pluginHost.password, undefined);
|
||||
assert.equal(pluginHost.requiresMfa, undefined);
|
||||
assert.equal(pluginHost.hostname, 'host.test');
|
||||
assert.equal(stripBuiltInConnectionFieldsForPluginHost({ ...base, protocol: 'ssh' }).totp, totp);
|
||||
});
|
||||
|
||||
test('plugin authentication URLs require HTTPS except for loopback HTTP callbacks', () => {
|
||||
assert.equal(isSafePluginAuthenticationUrl('https://login.example.com/authorize'), true);
|
||||
assert.equal(isSafePluginAuthenticationUrl('http://localhost:44123/callback'), true);
|
||||
|
||||
@@ -125,6 +125,7 @@ export function stripBuiltInConnectionFieldsForPluginHost(host: Host): Host {
|
||||
identityFileId: _identityFileId,
|
||||
identityFilePaths: _identityFilePaths,
|
||||
password: _password,
|
||||
totp: _totp,
|
||||
savePassword: _savePassword,
|
||||
authMethod: _authMethod,
|
||||
authPolicyVersion: _authPolicyVersion,
|
||||
|
||||
@@ -2,6 +2,7 @@ import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import {
|
||||
isQuickConnectInput,
|
||||
isSwitcherQuickConnectInput,
|
||||
parseQuickConnectInput,
|
||||
} from "./quickConnect";
|
||||
|
||||
@@ -119,3 +120,26 @@ test("quick connect rejects malformed multi-@ inputs", () => {
|
||||
assert.equal(parseQuickConnectInput("root@10.2.0.8@"), null);
|
||||
assert.equal(parseQuickConnectInput("root@bad host@jump"), null);
|
||||
});
|
||||
|
||||
test("switcher quick connect accepts only address-like input", () => {
|
||||
for (const label of ["lab", "web01", "prod", "lab-switch", "localhost"]) {
|
||||
assert.equal(isQuickConnectInput(label), true);
|
||||
assert.equal(isSwitcherQuickConnectInput(label), false);
|
||||
}
|
||||
|
||||
for (const address of [
|
||||
"10.1.1.1",
|
||||
"root@10.2.0.8:2222",
|
||||
"example.com",
|
||||
"host:2222",
|
||||
"user@lab",
|
||||
"[::1]",
|
||||
"fe80::1",
|
||||
"[2001:db8::1]:2222",
|
||||
]) {
|
||||
assert.equal(isSwitcherQuickConnectInput(address), true, address);
|
||||
}
|
||||
|
||||
assert.equal(isSwitcherQuickConnectInput("core switch"), false);
|
||||
assert.equal(isSwitcherQuickConnectInput("host:99999"), false);
|
||||
});
|
||||
@@ -360,3 +360,30 @@ export function parseQuickConnectInput(
|
||||
export function isQuickConnectInput(input: string): boolean {
|
||||
return parseQuickConnectInput(input) !== null;
|
||||
}
|
||||
|
||||
const SWITCHER_PORT_SUFFIX = /:(\d{1,5})$/;
|
||||
|
||||
/**
|
||||
* Tab-switcher quick connect is stricter than vault search. A single saved
|
||||
* label such as "lab" or "lab-switch" must stay a host search, not an address.
|
||||
* Address-like means `@`, a numeric :port, an IP, or a dot.
|
||||
*/
|
||||
export function isSwitcherQuickConnectInput(input: string): boolean {
|
||||
if (!isQuickConnectInput(input)) return false;
|
||||
const trimmed = input.trim();
|
||||
if (trimmed.includes("@") || trimmed.includes(".")) return true;
|
||||
|
||||
const portMatch = trimmed.match(SWITCHER_PORT_SUFFIX);
|
||||
if (portMatch) {
|
||||
const port = Number(portMatch[1]);
|
||||
if (port >= 1 && port <= 65535) return true;
|
||||
}
|
||||
|
||||
const hostToken = trimmed.split(/\s+/).pop() ?? trimmed;
|
||||
const withoutUser = hostToken.includes("@")
|
||||
? hostToken.slice(hostToken.lastIndexOf("@") + 1)
|
||||
: hostToken;
|
||||
const withoutPort = withoutUser.replace(SWITCHER_PORT_SUFFIX, "");
|
||||
const unbracketed = withoutPort.replace(/^\[|\]$/g, "");
|
||||
return isBareIPv6(unbracketed);
|
||||
}
|
||||
@@ -1,7 +1,9 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import en from "../application/i18n/locales/en.ts";
|
||||
import zhCN from "../application/i18n/locales/zh-CN.ts";
|
||||
import zhTW from "../application/i18n/locales/zh-TW.ts";
|
||||
import { filterSettingsSearchCatalog } from "./settingsSearch.ts";
|
||||
import { SETTINGS_SEARCH_CATALOG } from "./settingsSearchCatalog.ts";
|
||||
|
||||
@@ -122,6 +124,16 @@ test("settings search finds App Lock from the zh-CN locale", () => {
|
||||
assert.ok(hits.some((hit) => hit.entry.id === "system-app-lock"));
|
||||
});
|
||||
|
||||
test("settings search finds temporary network device mode", () => {
|
||||
const tEnReal = (key: string) => en[key] ?? key;
|
||||
const tZhTW = (key: string) => zhTW[key] ?? key;
|
||||
const english = filterSettingsSearchCatalog("network device", tEnReal);
|
||||
assert.ok(english.some((hit) => hit.entry.id === "terminal-temporary-network-device-mode"));
|
||||
|
||||
const traditional = filterSettingsSearchCatalog("網路裝置", tZhTW);
|
||||
assert.ok(traditional.some((hit) => hit.entry.id === "terminal-temporary-network-device-mode"));
|
||||
});
|
||||
|
||||
test("filterSettingsSearchCatalog can exclude plugins tab", () => {
|
||||
const hits = filterSettingsSearchCatalog("plugin", tEn, { includePlugins: false });
|
||||
assert.equal(hits.some((hit) => hit.entry.tab === "plugins"), false);
|
||||
|
||||
@@ -488,6 +488,14 @@ export const SETTINGS_SEARCH_CATALOG: readonly SettingsSearchEntry[] = [
|
||||
sectionKey: "settings.terminal.section.connection",
|
||||
keywords: ["legacy", "kex", "putty", "旧版算法", "演算法", "堡垒机"],
|
||||
},
|
||||
{
|
||||
id: "terminal-temporary-network-device-mode",
|
||||
tab: "terminal",
|
||||
labelKey: "settings.terminal.connection.temporaryNetworkDeviceMode",
|
||||
descriptionKey: "settings.terminal.connection.temporaryNetworkDeviceMode.desc",
|
||||
sectionKey: "settings.terminal.section.connection",
|
||||
keywords: ["network", "device", "switch", "router", "网络设备", "網路裝置"],
|
||||
},
|
||||
{
|
||||
id: "terminal-ssh-auto-reconnect",
|
||||
tab: "terminal",
|
||||
|
||||
@@ -0,0 +1,213 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import {
|
||||
prepareConnectionHost,
|
||||
temporaryConnectionPrepareOptions,
|
||||
} from "./legacyAlgorithms.ts";
|
||||
import { normalizeTerminalSettings } from "./models/terminal.ts";
|
||||
import type { Host } from "./models.ts";
|
||||
import { buildQuickConnectHost } from "./quickConnectHost.ts";
|
||||
|
||||
const host = (overrides: Partial<Host> = {}): Host => ({
|
||||
id: "host-1",
|
||||
label: "host",
|
||||
hostname: "bastion.example",
|
||||
username: "root",
|
||||
tags: [],
|
||||
os: "linux",
|
||||
protocol: "ssh",
|
||||
...overrides,
|
||||
});
|
||||
|
||||
test("a saved host keeps its own device type when temporary network device mode is on", () => {
|
||||
const saved = host({
|
||||
hostname: "10.0.0.8",
|
||||
deviceType: "general",
|
||||
});
|
||||
const unset = host({ hostname: "10.0.0.9" });
|
||||
const network = host({ hostname: "10.0.0.10", deviceType: "network" });
|
||||
|
||||
assert.equal(prepareConnectionHost(saved, {
|
||||
temporaryConnectionsNetworkDeviceMode: true,
|
||||
}), saved);
|
||||
assert.equal(prepareConnectionHost(unset, {
|
||||
temporaryConnectionsNetworkDeviceMode: true,
|
||||
}), unset);
|
||||
assert.equal(prepareConnectionHost(network, {
|
||||
temporaryConnectionsNetworkDeviceMode: true,
|
||||
}), network);
|
||||
});
|
||||
|
||||
test("an ephemeral clone of a saved host keeps that host's device type", () => {
|
||||
const cloned = host({
|
||||
id: "ephemeral-from-saved",
|
||||
ephemeral: true,
|
||||
backedBySavedHost: true,
|
||||
hostname: "10.0.0.8",
|
||||
deviceType: "general",
|
||||
});
|
||||
|
||||
assert.equal(prepareConnectionHost(cloned, {
|
||||
temporaryConnectionsNetworkDeviceMode: true,
|
||||
}), cloned);
|
||||
assert.equal(prepareConnectionHost(host({
|
||||
ephemeral: true,
|
||||
backedBySavedHost: true,
|
||||
deviceType: "network",
|
||||
}), {
|
||||
temporaryConnectionsNetworkDeviceMode: false,
|
||||
}).deviceType, "network");
|
||||
});
|
||||
|
||||
test("a temporary SSH connection uses network device mode only when the setting is on", () => {
|
||||
const temporary = host({
|
||||
id: "ephemeral-1",
|
||||
ephemeral: true,
|
||||
hostname: "10.1.1.1",
|
||||
port: 22,
|
||||
});
|
||||
|
||||
assert.equal(prepareConnectionHost(temporary, {
|
||||
temporaryConnectionsNetworkDeviceMode: true,
|
||||
}).deviceType, "network");
|
||||
assert.equal(prepareConnectionHost(temporary, {
|
||||
temporaryConnectionsNetworkDeviceMode: false,
|
||||
}), temporary);
|
||||
assert.equal(prepareConnectionHost(temporary).deviceType, undefined);
|
||||
});
|
||||
|
||||
test("temporary network device mode does not clear an explicit device type when the setting is off", () => {
|
||||
const temporary = host({
|
||||
ephemeral: true,
|
||||
hostname: "10.1.1.1",
|
||||
deviceType: "network",
|
||||
});
|
||||
|
||||
assert.equal(prepareConnectionHost(temporary, {
|
||||
temporaryConnectionsNetworkDeviceMode: false,
|
||||
}), temporary);
|
||||
});
|
||||
|
||||
test("temporary network device mode can be combined with the legacy algorithm opt-in", () => {
|
||||
const temporary = host({
|
||||
ephemeral: true,
|
||||
hostname: "192.0.2.10",
|
||||
});
|
||||
|
||||
const prepared = prepareConnectionHost(temporary, {
|
||||
temporaryConnectionsAllowLegacyAlgorithms: true,
|
||||
temporaryConnectionsNetworkDeviceMode: true,
|
||||
});
|
||||
|
||||
assert.equal(prepared.legacyAlgorithms, true);
|
||||
assert.equal(prepared.deviceType, "network");
|
||||
assert.equal(prepared.hostname, "192.0.2.10");
|
||||
});
|
||||
|
||||
test("temporary Mosh and ET connections do not receive network device mode", () => {
|
||||
const mosh = host({
|
||||
ephemeral: true,
|
||||
protocol: "ssh",
|
||||
moshEnabled: true,
|
||||
hostname: "10.1.1.1",
|
||||
});
|
||||
const et = host({
|
||||
ephemeral: true,
|
||||
protocol: "ssh",
|
||||
etEnabled: true,
|
||||
hostname: "10.1.1.1",
|
||||
});
|
||||
const options = {
|
||||
temporaryConnectionsAllowLegacyAlgorithms: true,
|
||||
temporaryConnectionsNetworkDeviceMode: true,
|
||||
};
|
||||
|
||||
assert.equal(prepareConnectionHost(mosh, options).deviceType, undefined);
|
||||
assert.equal(prepareConnectionHost(mosh, options).legacyAlgorithms, true);
|
||||
assert.equal(prepareConnectionHost(et, options).deviceType, undefined);
|
||||
assert.equal(prepareConnectionHost(et, options).legacyAlgorithms, true);
|
||||
});
|
||||
|
||||
test("temporary telnet connections do not receive network device mode", () => {
|
||||
const telnet = host({
|
||||
ephemeral: true,
|
||||
protocol: "telnet",
|
||||
hostname: "10.1.1.1",
|
||||
});
|
||||
|
||||
assert.equal(prepareConnectionHost(telnet, {
|
||||
temporaryConnectionsNetworkDeviceMode: true,
|
||||
}), telnet);
|
||||
});
|
||||
|
||||
test("an unsaved quick connect host receives temporary connection defaults", () => {
|
||||
const quickHost = buildQuickConnectHost({
|
||||
id: "quick-1",
|
||||
createdAt: 1,
|
||||
target: { hostname: "10.1.1.1" },
|
||||
protocol: "ssh",
|
||||
port: 22,
|
||||
username: "admin",
|
||||
authMethod: "password",
|
||||
password: "secret",
|
||||
});
|
||||
|
||||
const prepared = prepareConnectionHost(quickHost, {
|
||||
temporaryConnectionsAllowLegacyAlgorithms: true,
|
||||
temporaryConnectionsNetworkDeviceMode: true,
|
||||
});
|
||||
const saved = prepareConnectionHost({ ...quickHost, ephemeral: false }, {
|
||||
temporaryConnectionsAllowLegacyAlgorithms: true,
|
||||
temporaryConnectionsNetworkDeviceMode: true,
|
||||
});
|
||||
|
||||
assert.equal(prepared.ephemeral, true);
|
||||
assert.equal(prepared.legacyAlgorithms, true);
|
||||
assert.equal(prepared.deviceType, "network");
|
||||
assert.equal(saved.deviceType, undefined);
|
||||
assert.equal(saved.legacyAlgorithms, undefined);
|
||||
});
|
||||
|
||||
test("temporary connection toggles are read from the settings object at connect time", () => {
|
||||
const settings = {
|
||||
temporaryConnectionsAllowLegacyAlgorithms: false,
|
||||
temporaryConnectionsNetworkDeviceMode: false,
|
||||
};
|
||||
const temporary = host({
|
||||
id: "ephemeral-live",
|
||||
ephemeral: true,
|
||||
hostname: "10.9.9.9",
|
||||
});
|
||||
const connect = () => prepareConnectionHost(
|
||||
temporary,
|
||||
temporaryConnectionPrepareOptions(settings),
|
||||
);
|
||||
|
||||
assert.equal(connect(), temporary);
|
||||
|
||||
settings.temporaryConnectionsNetworkDeviceMode = true;
|
||||
settings.temporaryConnectionsAllowLegacyAlgorithms = true;
|
||||
const enabled = connect();
|
||||
assert.equal(enabled.deviceType, "network");
|
||||
assert.equal(enabled.legacyAlgorithms, true);
|
||||
|
||||
settings.temporaryConnectionsNetworkDeviceMode = false;
|
||||
settings.temporaryConnectionsAllowLegacyAlgorithms = false;
|
||||
assert.equal(connect(), temporary);
|
||||
});
|
||||
|
||||
test("temporary connection network device mode defaults to off", () => {
|
||||
assert.equal(normalizeTerminalSettings().temporaryConnectionsNetworkDeviceMode, false);
|
||||
assert.equal(
|
||||
normalizeTerminalSettings({ temporaryConnectionsNetworkDeviceMode: true })
|
||||
.temporaryConnectionsNetworkDeviceMode,
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
normalizeTerminalSettings({
|
||||
temporaryConnectionsNetworkDeviceMode: "yes" as never,
|
||||
}).temporaryConnectionsNetworkDeviceMode,
|
||||
false,
|
||||
);
|
||||
});
|
||||
@@ -77,6 +77,8 @@ function isConfirmedTerminalShellPrompt(promptText, options = {}) {
|
||||
if (code >= 0xE000 && code <= 0xF8FF) return true;
|
||||
}
|
||||
if (/[$#%]$/u.test(prompt)) return true;
|
||||
// POSIX sh/bash/ksh default PS2. Trusted only with heredoc context (#3796).
|
||||
if (prompt === ">") return isHeredocPs2Continuation(promptText, options);
|
||||
if (!prompt.endsWith(">")) return false;
|
||||
if (/^(?:PS\s+)?[A-Za-z]:[\\/].*>$/u.test(prompt)) return true;
|
||||
if (/[@\\/~:]\S*>$/u.test(prompt)) return true;
|
||||
@@ -84,6 +86,53 @@ function isConfirmedTerminalShellPrompt(promptText, options = {}) {
|
||||
&& /^[A-Za-z0-9_.-]+(?:\([^)]{1,128}\))?>$/u.test(prompt);
|
||||
}
|
||||
|
||||
function splitTerminalLogicalLines(value) {
|
||||
const lines = [];
|
||||
let start = 0;
|
||||
for (let i = 0; i < value.length; i += 1) {
|
||||
const ch = value[i];
|
||||
if (ch !== "\n" && ch !== "\r") continue;
|
||||
lines.push(value.slice(start, i));
|
||||
if (ch === "\r" && value[i + 1] === "\n") i += 1;
|
||||
start = i + 1;
|
||||
}
|
||||
lines.push(value.slice(start));
|
||||
return lines;
|
||||
}
|
||||
|
||||
function isBashPs2ContinuationLine(line) {
|
||||
const trimmed = line.trim();
|
||||
return trimmed === ">" || trimmed.startsWith("> ");
|
||||
}
|
||||
|
||||
function commandAfterConfirmedShellPrompt(line, options) {
|
||||
const text = line.trim();
|
||||
for (let i = 0; i < text.length - 1; i += 1) {
|
||||
const ch = text[i];
|
||||
if (ch !== "$" && ch !== "#" && ch !== "%" && ch !== ">") continue;
|
||||
const rest = text.slice(i + 1);
|
||||
if (!/^\s+\S/u.test(rest)) continue;
|
||||
const candidate = text.slice(0, i + 1);
|
||||
if (!isConfirmedTerminalShellPrompt(candidate, options)) continue;
|
||||
if ((ch === "$" || ch === "#" || ch === "%") && !isPlausibleShellPromptBody(candidate.slice(0, -1))) {
|
||||
continue;
|
||||
}
|
||||
return rest;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// Bare ">" is bash PS2 only inside a heredoc opened by a confirmed shell
|
||||
// command (#3796). read -s -p '> ' and other origins stay fail-closed.
|
||||
function isHeredocPs2Continuation(value, options) {
|
||||
const lines = splitTerminalLogicalLines(stripTerminalControlSequences(value));
|
||||
let index = lines.length - 2;
|
||||
while (index >= 0 && isBashPs2ContinuationLine(lines[index])) index -= 1;
|
||||
if (index < 0) return false;
|
||||
const command = commandAfterConfirmedShellPrompt(lines[index], options);
|
||||
return command != null && command.includes("<<");
|
||||
}
|
||||
|
||||
function isPlausibleShellPromptBody(body) {
|
||||
const trimmed = body.trim();
|
||||
if (!trimmed) return true;
|
||||
@@ -117,7 +166,8 @@ function isUntrustedTerminalInputPrompt(value, options = {}) {
|
||||
if (options.alternateScreen && /^:\s*$/.test(prompt)) return false;
|
||||
if (!/[::>›»]\s*$/u.test(prompt)) return false;
|
||||
if (hasTypedInputAfterConfirmedPrompt(prompt, options)) return false;
|
||||
return !isConfirmedTerminalShellPrompt(prompt, options);
|
||||
// Pass the original text so a bare ">" can see heredoc lines above it.
|
||||
return !isConfirmedTerminalShellPrompt(value, options);
|
||||
}
|
||||
|
||||
module.exports = { isUntrustedTerminalInputPrompt };
|
||||
@@ -4,6 +4,7 @@ import test from 'node:test';
|
||||
|
||||
import {
|
||||
appendTerminalPromptSecurityTail,
|
||||
foldTerminalPromptSecurityOutput,
|
||||
isConfirmedTerminalShellPrompt,
|
||||
isSensitiveTerminalChallenge,
|
||||
isUntrustedTerminalInputPrompt,
|
||||
@@ -122,6 +123,69 @@ test('ordinary shell commands ending with a colon are not untrusted input prompt
|
||||
assert.equal(isUntrustedTerminalInputPrompt('Password: '), true);
|
||||
});
|
||||
|
||||
test('bash PS2 is trusted only while a heredoc continuation is open (#3796)', () => {
|
||||
// CentOS-7-style bash has no bracketed paste, so a heredoc paste echoes the
|
||||
// default PS2 "> " after each line. That must keep broadcast on.
|
||||
const heredocEcho = '[root@h ~]# cat >> f << EOF\r\n> ';
|
||||
assert.equal(isConfirmedTerminalShellPrompt(heredocEcho), true);
|
||||
assert.equal(isUntrustedTerminalInputPrompt(heredocEcho), false);
|
||||
const heredocBody = '[root@h ~]# cat >> f << EOF\r\n> echo madvise > /sys/kernel/mm/transparent_hugepage/enabled\r\n> ';
|
||||
assert.equal(isConfirmedTerminalShellPrompt(heredocBody), true);
|
||||
assert.equal(isUntrustedTerminalInputPrompt(heredocBody), false);
|
||||
// No preceding command, or an echo-disabled prompt labelled ">", fails closed.
|
||||
assert.equal(isConfirmedTerminalShellPrompt('>'), false);
|
||||
assert.equal(isConfirmedTerminalShellPrompt('> '), false);
|
||||
assert.equal(isUntrustedTerminalInputPrompt('>'), true);
|
||||
assert.equal(isUntrustedTerminalInputPrompt('> '), true);
|
||||
const secretPrompt = "[root@h ~]# read -s -p '> ' secret\r\n> ";
|
||||
assert.equal(isConfirmedTerminalShellPrompt(secretPrompt), false);
|
||||
assert.equal(isUntrustedTerminalInputPrompt(secretPrompt), true);
|
||||
// The echoed terminator line is ordinary output, not a prompt latch.
|
||||
assert.equal(isConfirmedTerminalShellPrompt('> EOF'), false);
|
||||
assert.equal(isUntrustedTerminalInputPrompt('> EOF'), false);
|
||||
// Labelled challenges stay fail-closed. zsh "heredoc>" is out of scope.
|
||||
assert.equal(isUntrustedTerminalInputPrompt('Custom>'), true);
|
||||
assert.equal(isUntrustedTerminalInputPrompt('Token>'), true);
|
||||
assert.equal(isUntrustedTerminalInputPrompt('Password:'), true);
|
||||
assert.equal(isConfirmedTerminalShellPrompt('Custom>'), false);
|
||||
assert.equal(isConfirmedTerminalShellPrompt('Token>'), false);
|
||||
assert.equal(isConfirmedTerminalShellPrompt('Password:'), false);
|
||||
assert.equal(isUntrustedTerminalInputPrompt('heredoc>'), true);
|
||||
assert.equal(isConfirmedTerminalShellPrompt('heredoc>'), false);
|
||||
});
|
||||
|
||||
test('prompt observation keeps the heredoc opener and drops it after the shell returns (#3796)', () => {
|
||||
let state = { origin: '', current: '' };
|
||||
state = foldTerminalPromptSecurityOutput(state, '[root@h ~]# cat >> f << EOF\r\n');
|
||||
state = foldTerminalPromptSecurityOutput(state, '> echo madvise > /sys/kernel/mm/transparent_hugepage/enabled\r\n');
|
||||
state = foldTerminalPromptSecurityOutput(state, '> ');
|
||||
assert.equal(isConfirmedTerminalShellPrompt(state.promptText), true);
|
||||
assert.equal(isUntrustedTerminalInputPrompt(state.promptText), false);
|
||||
|
||||
state = foldTerminalPromptSecurityOutput(state, "\r\n[root@h ~]# ");
|
||||
state = foldTerminalPromptSecurityOutput(state, '\r\n> ');
|
||||
assert.equal(isConfirmedTerminalShellPrompt(state.promptText), false);
|
||||
assert.equal(isUntrustedTerminalInputPrompt(state.promptText), true);
|
||||
|
||||
state = { origin: '', current: '' };
|
||||
state = foldTerminalPromptSecurityOutput(state, "[root@h ~]# read -s -p '> ' secret\r\n");
|
||||
state = foldTerminalPromptSecurityOutput(state, '> ');
|
||||
assert.equal(isConfirmedTerminalShellPrompt(state.promptText), false);
|
||||
assert.equal(isUntrustedTerminalInputPrompt(state.promptText), true);
|
||||
|
||||
state = foldTerminalPromptSecurityOutput({ origin: '', current: '' }, '> ');
|
||||
assert.equal(isUntrustedTerminalInputPrompt(state.promptText), true);
|
||||
assert.equal(isConfirmedTerminalShellPrompt(state.promptText), false);
|
||||
});
|
||||
|
||||
test('prompt observation coalesces a CRLF split across chunks (#3796)', () => {
|
||||
let state = { origin: '', current: '' };
|
||||
state = foldTerminalPromptSecurityOutput(state, "[root@h ~]# cat >> f << EOF\r");
|
||||
state = foldTerminalPromptSecurityOutput(state, '\n> ');
|
||||
assert.equal(isConfirmedTerminalShellPrompt(state.promptText), true);
|
||||
assert.equal(isUntrustedTerminalInputPrompt(state.promptText), false);
|
||||
});
|
||||
|
||||
test('vim ex command-line is not an untrusted input prompt (#3422)', () => {
|
||||
assert.equal(isUntrustedTerminalInputPrompt(':', { alternateScreen: true }), false);
|
||||
assert.equal(isUntrustedTerminalInputPrompt(': ', { alternateScreen: true }), false);
|
||||
@@ -147,6 +211,14 @@ test('renderer and main-process prompt classifiers stay aligned', () => {
|
||||
['Challenge # 1:', undefined, true],
|
||||
['router> show ip:', { allowHostStyleGreaterThan: true }, false],
|
||||
['router> show ip:', undefined, true],
|
||||
['>', undefined, true],
|
||||
['> ', undefined, true],
|
||||
['[root@h ~]# cat >> f << EOF\r\n> ', undefined, false],
|
||||
['[root@h ~]# cat >> f << EOF\r\n> echo madvise > /sys/kernel/mm/transparent_hugepage/enabled\r\n> ', undefined, false],
|
||||
["[root@h ~]# read -s -p '> ' secret\r\n> ", undefined, true],
|
||||
['Custom>', undefined, true],
|
||||
['Token>', undefined, true],
|
||||
['Password:', undefined, true],
|
||||
];
|
||||
|
||||
for (const [prompt, options, expected] of cases) {
|
||||
|
||||
@@ -72,6 +72,45 @@ export function appendTerminalPromptSecurityTail(previous: string, chunk: string
|
||||
return combined.slice(boundary + 1).slice(-MAX_PROMPT_SECURITY_TAIL_CHARS);
|
||||
}
|
||||
|
||||
function nextHeredocPromptOrigin(origin: string, completedLine: string): string {
|
||||
const plain = stripTerminalControlSequences(completedLine).trim();
|
||||
// A blank line ends the continuation. A PS2 echo (">" or "> ...") does not.
|
||||
if (isBashPs2ContinuationLine(plain)) return origin;
|
||||
return plain.slice(0, MAX_PROMPT_SECURITY_TAIL_CHARS);
|
||||
}
|
||||
|
||||
/**
|
||||
* Current logical line, plus the heredoc opener when the line is a bare ">".
|
||||
* `appendTerminalPromptSecurityTail` keeps only the current line, which is all
|
||||
* `observeTerminalInputPrompt` used to pass in. The opener is the last
|
||||
* completed non-PS2 line, so a long heredoc body does not have to be retained.
|
||||
*/
|
||||
export function foldTerminalPromptSecurityOutput(
|
||||
state: { origin: string; current: string; pendingCr?: boolean },
|
||||
chunk: string,
|
||||
): { origin: string; current: string; promptText: string; pendingCr: boolean } {
|
||||
// A PTY can split CRLF across chunks. The LF is the rest of that break,
|
||||
// not a second empty line that would drop the heredoc opener.
|
||||
let pendingCr = state.pendingCr === true;
|
||||
let input = chunk;
|
||||
if (pendingCr && input.startsWith('\n')) {
|
||||
input = input.slice(1);
|
||||
pendingCr = false;
|
||||
}
|
||||
const combined = `${state.current}${input}`;
|
||||
let origin = state.origin;
|
||||
for (const line of splitTerminalLogicalLines(combined).slice(0, -1)) {
|
||||
origin = nextHeredocPromptOrigin(origin, line);
|
||||
}
|
||||
const current = appendTerminalPromptSecurityTail(state.current, input);
|
||||
const promptText = lastLogicalLine(current).trim() === '>' && origin
|
||||
? `${origin}\n${current}`
|
||||
: current;
|
||||
if (input.endsWith('\r')) pendingCr = true;
|
||||
else if (input.length > 0) pendingCr = false;
|
||||
return { origin, current, promptText, pendingCr };
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect a prompt-shaped authentication challenge. Vocabulary intentionally
|
||||
* matches the SSH keyboard-interactive boundary and also covers PIN/auth-code
|
||||
@@ -120,6 +159,8 @@ export function isConfirmedTerminalShellPrompt(
|
||||
if (code >= 0xE000 && code <= 0xF8FF) return true;
|
||||
}
|
||||
if (/[$#%]$/u.test(prompt)) return true;
|
||||
// POSIX sh/bash/ksh default PS2. Trusted only with heredoc context (#3796).
|
||||
if (prompt === '>') return isHeredocPs2Continuation(promptText, options);
|
||||
if (!prompt.endsWith('>')) return false;
|
||||
if (/^(?:PS\s+)?[A-Za-z]:[\\/].*>$/u.test(prompt)) return true;
|
||||
if (/[@\\/~:]\S*>$/u.test(prompt)) return true;
|
||||
@@ -139,6 +180,64 @@ export function shouldUsePluginTerminalCompletionProvider(input: {
|
||||
});
|
||||
}
|
||||
|
||||
function splitTerminalLogicalLines(value: string): string[] {
|
||||
const lines: string[] = [];
|
||||
let start = 0;
|
||||
for (let i = 0; i < value.length; i += 1) {
|
||||
const ch = value[i];
|
||||
if (ch !== '\n' && ch !== '\r') continue;
|
||||
lines.push(value.slice(start, i));
|
||||
if (ch === '\r' && value[i + 1] === '\n') i += 1;
|
||||
start = i + 1;
|
||||
}
|
||||
lines.push(value.slice(start));
|
||||
return lines;
|
||||
}
|
||||
|
||||
/** Bash default PS2 echo: ">" or "> ...". Not "heredoc>" / "Custom>". */
|
||||
function isBashPs2ContinuationLine(line: string): boolean {
|
||||
const trimmed = line.trim();
|
||||
return trimmed === '>' || trimmed.startsWith('> ');
|
||||
}
|
||||
|
||||
/**
|
||||
* Command text after the first confirmed shell prompt on this line.
|
||||
* Returns null when the line is not a confirmed prompt plus typed input.
|
||||
*/
|
||||
function commandAfterConfirmedShellPrompt(
|
||||
line: string,
|
||||
options: ConfirmedPromptOptions,
|
||||
): string | null {
|
||||
const text = line.trim();
|
||||
for (let i = 0; i < text.length - 1; i += 1) {
|
||||
const ch = text[i];
|
||||
if (ch !== '$' && ch !== '#' && ch !== '%' && ch !== '>') continue;
|
||||
const rest = text.slice(i + 1);
|
||||
if (!/^\s+\S/u.test(rest)) continue;
|
||||
const candidate = text.slice(0, i + 1);
|
||||
if (!isConfirmedTerminalShellPrompt(candidate, options)) continue;
|
||||
if ((ch === '$' || ch === '#' || ch === '%') && !isPlausibleShellPromptBody(candidate.slice(0, -1))) {
|
||||
continue;
|
||||
}
|
||||
return rest;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Bare ">" is bash PS2 only while output is still inside a heredoc opened by
|
||||
* a confirmed shell command (#3796). Any other origin, including
|
||||
* `read -s -p '> '`, stays fail-closed.
|
||||
*/
|
||||
function isHeredocPs2Continuation(value: string, options: ConfirmedPromptOptions): boolean {
|
||||
const lines = splitTerminalLogicalLines(stripTerminalControlSequences(value));
|
||||
let index = lines.length - 2;
|
||||
while (index >= 0 && isBashPs2ContinuationLine(lines[index])) index -= 1;
|
||||
if (index < 0) return false;
|
||||
const command = commandAfterConfirmedShellPrompt(lines[index], options);
|
||||
return command != null && command.includes('<<');
|
||||
}
|
||||
|
||||
/**
|
||||
* Body text before a `$` / `#` / `%` terminator that is plausible as a shell
|
||||
* PS1 (bare marker, user@host, path, shell-version, or lowercase identity).
|
||||
@@ -204,7 +303,8 @@ export function isUntrustedTerminalInputPrompt(
|
||||
// Mid-command punctuation after a real shell prompt must keep broadcasting
|
||||
// (#2709). Standalone `Label:` / `Custom>` challenges still fail closed.
|
||||
if (hasTypedInputAfterConfirmedPrompt(prompt, options)) return false;
|
||||
return !isConfirmedTerminalShellPrompt(prompt, options);
|
||||
// Pass the original text so a bare ">" can see heredoc lines above it.
|
||||
return !isConfirmedTerminalShellPrompt(value, options);
|
||||
}
|
||||
|
||||
export { MAX_PROMPT_SECURITY_TAIL_CHARS };
|
||||
@@ -0,0 +1,290 @@
|
||||
"use strict";
|
||||
|
||||
/* global Buffer, URL, URLSearchParams */
|
||||
|
||||
const crypto = require("node:crypto");
|
||||
const { NON_TOTP_PROMPT } = require("./totpPrompt.shared.cjs");
|
||||
|
||||
const BASE32_ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567";
|
||||
const ALGORITHMS = new Set(["SHA1", "SHA256", "SHA512"]);
|
||||
const HMAC_NAMES = { SHA1: "sha1", SHA256: "sha256", SHA512: "sha512" };
|
||||
const BUILTIN_TOTP_PROMPTS = [
|
||||
/^\[OTP Code\]:\s*$/,
|
||||
/^\[MFA auth\]:\s*$/,
|
||||
/^Verification code:\s*$/,
|
||||
];
|
||||
// SMS, email, RADIUS, menus, and confirmations must never be auto-answered,
|
||||
// even when a host regex would otherwise match. The pattern lives in
|
||||
// totpPrompt.shared.cjs so the auth dialog can use the same list.
|
||||
const MAX_PROMPT_PATTERN_LENGTH = 200;
|
||||
const MAX_PROMPT_SUBJECT_LENGTH = 1024;
|
||||
let compileBoundedPattern = null;
|
||||
|
||||
function boundedPatternMatches(pattern, text) {
|
||||
if (!compileBoundedPattern) {
|
||||
const { RE2JS } = require("re2js");
|
||||
compileBoundedPattern = (source) => RE2JS.compile(source);
|
||||
}
|
||||
return compileBoundedPattern(pattern).matcher(text).find();
|
||||
}
|
||||
const MIN_SECRET_BYTES = 10;
|
||||
const MAX_SECRET_BYTES = 128;
|
||||
|
||||
function decodeBase32(input) {
|
||||
const cleaned = String(input || "").replace(/=+$/g, "").replace(/\s+/g, "").toUpperCase();
|
||||
if (!cleaned || /[^A-Z2-7]/.test(cleaned)) return null;
|
||||
let bits = 0;
|
||||
let value = 0;
|
||||
const out = [];
|
||||
for (const char of cleaned) {
|
||||
value = (value << 5) | BASE32_ALPHABET.indexOf(char);
|
||||
bits += 5;
|
||||
if (bits >= 8) {
|
||||
bits -= 8;
|
||||
out.push((value >>> bits) & 0xff);
|
||||
}
|
||||
}
|
||||
return Buffer.from(out);
|
||||
}
|
||||
|
||||
function encodeBase32(buffer) {
|
||||
let bits = 0;
|
||||
let value = 0;
|
||||
let output = "";
|
||||
for (const byte of buffer) {
|
||||
value = (value << 8) | byte;
|
||||
bits += 8;
|
||||
while (bits >= 5) {
|
||||
bits -= 5;
|
||||
output += BASE32_ALPHABET[(value >>> bits) & 31];
|
||||
}
|
||||
}
|
||||
if (bits > 0) output += BASE32_ALPHABET[(value << (5 - bits)) & 31];
|
||||
return output;
|
||||
}
|
||||
|
||||
function generateTotp({ secret, algorithm = "SHA1", digits = 6, period = 30, timestamp = Date.now() }) {
|
||||
const algo = HMAC_NAMES[String(algorithm || "SHA1").toUpperCase()];
|
||||
if (!algo) throw new Error("invalid-algorithm");
|
||||
if (!Number.isInteger(digits) || digits < 6 || digits > 8) throw new Error("invalid-digits");
|
||||
if (!Number.isInteger(period) || period < 1) throw new Error("invalid-period");
|
||||
if (!Buffer.isBuffer(secret) || secret.length === 0) throw new Error("invalid-secret");
|
||||
const counter = BigInt(Math.floor(Number(timestamp) / 1000 / period));
|
||||
const message = Buffer.alloc(8);
|
||||
message.writeBigUInt64BE(counter);
|
||||
const hash = crypto.createHmac(algo, secret).update(message).digest();
|
||||
const offset = hash[hash.length - 1] & 0x0f;
|
||||
const binary = ((hash[offset] & 0x7f) << 24)
|
||||
| ((hash[offset + 1] & 0xff) << 16)
|
||||
| ((hash[offset + 2] & 0xff) << 8)
|
||||
| (hash[offset + 3] & 0xff);
|
||||
return String(binary % (10 ** digits)).padStart(digits, "0");
|
||||
}
|
||||
|
||||
function buildOtpauthUri({ secret, algorithm, digits, period, issuer, label }) {
|
||||
const account = label || issuer || "TOTP";
|
||||
const pathLabel = issuer && label && !label.startsWith(`${issuer}:`)
|
||||
? `${issuer}:${label}`
|
||||
: account;
|
||||
const params = new URLSearchParams();
|
||||
params.set("secret", encodeBase32(secret));
|
||||
params.set("algorithm", algorithm);
|
||||
params.set("digits", String(digits));
|
||||
params.set("period", String(period));
|
||||
if (issuer) params.set("issuer", issuer);
|
||||
return `otpauth://totp/${encodeURIComponent(pathLabel)}?${params.toString()}`;
|
||||
}
|
||||
|
||||
function parseRawSecret(text) {
|
||||
const secret = decodeBase32(text);
|
||||
if (!secret || secret.length < MIN_SECRET_BYTES || secret.length > MAX_SECRET_BYTES) {
|
||||
return { ok: false, error: "invalid-secret" };
|
||||
}
|
||||
return finalizeParsed({
|
||||
secret,
|
||||
algorithm: "SHA1",
|
||||
digits: 6,
|
||||
period: 30,
|
||||
issuer: "",
|
||||
label: "",
|
||||
});
|
||||
}
|
||||
|
||||
function finalizeParsed(fields) {
|
||||
return {
|
||||
ok: true,
|
||||
...fields,
|
||||
uri: buildOtpauthUri(fields),
|
||||
};
|
||||
}
|
||||
|
||||
function parseOtpauthUri(text) {
|
||||
let url;
|
||||
try {
|
||||
url = new URL(text);
|
||||
} catch {
|
||||
return { ok: false, error: "invalid-uri" };
|
||||
}
|
||||
const type = (url.hostname || "").toLowerCase();
|
||||
if (!type) return { ok: false, error: "invalid-uri" };
|
||||
if (type === "hotp") return { ok: false, error: "hotp" };
|
||||
if (type !== "totp") return { ok: false, error: "unsupported-type" };
|
||||
const secretParam = url.searchParams.get("secret");
|
||||
if (!secretParam || !secretParam.trim()) return { ok: false, error: "missing-secret" };
|
||||
const secret = decodeBase32(secretParam);
|
||||
if (!secret || secret.length < MIN_SECRET_BYTES || secret.length > MAX_SECRET_BYTES) {
|
||||
return { ok: false, error: "invalid-secret" };
|
||||
}
|
||||
const algorithm = String(url.searchParams.get("algorithm") || "SHA1").replace(/-/g, "").toUpperCase();
|
||||
if (!ALGORITHMS.has(algorithm)) return { ok: false, error: "invalid-algorithm" };
|
||||
const digitsText = url.searchParams.get("digits");
|
||||
const digits = digitsText == null || digitsText.trim() === "" ? 6 : Number(digitsText);
|
||||
if (!Number.isInteger(digits) || digits < 6 || digits > 8) return { ok: false, error: "invalid-digits" };
|
||||
const periodText = url.searchParams.get("period");
|
||||
const period = periodText == null || periodText.trim() === "" ? 30 : Number(periodText);
|
||||
if (!Number.isInteger(period) || period < 1 || period > 300) return { ok: false, error: "invalid-period" };
|
||||
|
||||
const rawLabel = decodeURIComponent(url.pathname.replace(/^\//, "")).trim();
|
||||
let issuer = (url.searchParams.get("issuer") || "").trim();
|
||||
let label = rawLabel;
|
||||
if (!issuer && rawLabel.includes(":")) {
|
||||
const splitAt = rawLabel.indexOf(":");
|
||||
issuer = rawLabel.slice(0, splitAt).trim();
|
||||
label = rawLabel.slice(splitAt + 1).trim();
|
||||
} else if (issuer && rawLabel.toLowerCase().startsWith(`${issuer.toLowerCase()}:`)) {
|
||||
label = rawLabel.slice(issuer.length + 1).trim();
|
||||
}
|
||||
return finalizeParsed({ secret, algorithm, digits, period, issuer, label });
|
||||
}
|
||||
|
||||
function parseTotpInput(input) {
|
||||
const text = String(input ?? "").trim();
|
||||
if (!text) return { ok: false, error: "empty" };
|
||||
if (/^otpauth-migration:/i.test(text)) return { ok: false, error: "migration" };
|
||||
if (/^otpauth:/i.test(text)) return parseOtpauthUri(text);
|
||||
return parseRawSecret(text);
|
||||
}
|
||||
|
||||
function currentTotp(parsed, timestamp = Date.now()) {
|
||||
const periodMs = parsed.period * 1000;
|
||||
const into = Number(timestamp) % periodMs;
|
||||
const remainingMs = into === 0 ? periodMs : periodMs - into;
|
||||
return {
|
||||
code: generateTotp({ ...parsed, timestamp }),
|
||||
secondsRemaining: Math.ceil(remainingMs / 1000),
|
||||
period: parsed.period,
|
||||
};
|
||||
}
|
||||
|
||||
function planTotpSubmission(parsed, timestamp = Date.now()) {
|
||||
const periodMs = parsed.period * 1000;
|
||||
const into = Number(timestamp) % periodMs;
|
||||
const remainingMs = into === 0 ? periodMs : periodMs - into;
|
||||
if (remainingMs <= 2000) {
|
||||
return {
|
||||
code: generateTotp({ ...parsed, timestamp: Number(timestamp) + remainingMs }),
|
||||
delayMs: remainingMs,
|
||||
};
|
||||
}
|
||||
return {
|
||||
code: generateTotp({ ...parsed, timestamp }),
|
||||
delayMs: 0,
|
||||
};
|
||||
}
|
||||
|
||||
function previewTotpText(input, timestamp = Date.now()) {
|
||||
const parsed = parseTotpInput(input);
|
||||
if (!parsed.ok) return { ok: false, error: parsed.error };
|
||||
const current = currentTotp(parsed, timestamp);
|
||||
return {
|
||||
ok: true,
|
||||
uri: parsed.uri,
|
||||
code: current.code,
|
||||
secondsRemaining: current.secondsRemaining,
|
||||
period: parsed.period,
|
||||
algorithm: parsed.algorithm,
|
||||
digits: parsed.digits,
|
||||
issuer: parsed.issuer,
|
||||
label: parsed.label,
|
||||
};
|
||||
}
|
||||
|
||||
function isExcludedTotpPrompt(prompts, name, instructions) {
|
||||
const promptText = typeof prompts?.[0]?.prompt === "string" ? prompts[0].prompt : "";
|
||||
const context = [name, instructions, promptText]
|
||||
.filter((part) => typeof part === "string" && part.trim())
|
||||
.join("\n");
|
||||
return NON_TOTP_PROMPT.test(promptText) || NON_TOTP_PROMPT.test(context);
|
||||
}
|
||||
|
||||
function isAutoSubmittableTotpPrompt({ prompts, name, instructions, promptPattern } = {}) {
|
||||
if (!Array.isArray(prompts) || prompts.length !== 1) return false;
|
||||
if (isExcludedTotpPrompt(prompts, name, instructions)) return false;
|
||||
const promptText = typeof prompts[0]?.prompt === "string" ? prompts[0].prompt : "";
|
||||
const pattern = typeof promptPattern === "string" ? promptPattern.trim() : "";
|
||||
if (
|
||||
pattern
|
||||
&& pattern.length <= MAX_PROMPT_PATTERN_LENGTH
|
||||
&& promptText.length <= MAX_PROMPT_SUBJECT_LENGTH
|
||||
) {
|
||||
try {
|
||||
if (boundedPatternMatches(pattern, promptText)) return true;
|
||||
} catch {
|
||||
// An invalid or non-RE2 pattern still leaves the built-in prompts available.
|
||||
}
|
||||
}
|
||||
return BUILTIN_TOTP_PROMPTS.some((pattern) => pattern.test(promptText));
|
||||
}
|
||||
|
||||
function planTotpAutoSubmit({ uri, prompts, name, instructions, promptPattern, autoSubmit, timestamp }) {
|
||||
if (autoSubmit === false) return { action: "skip" };
|
||||
if (typeof uri !== "string" || !uri || uri.startsWith("enc:v1:")) return { action: "skip" };
|
||||
if (!isAutoSubmittableTotpPrompt({ prompts, name, instructions, promptPattern })) {
|
||||
return { action: "skip" };
|
||||
}
|
||||
const parsed = parseTotpInput(uri);
|
||||
if (!parsed.ok) return { action: "fail" };
|
||||
return { action: "submit", ...planTotpSubmission(parsed, timestamp) };
|
||||
}
|
||||
|
||||
function selectTotpFromQrPayloads(payloads) {
|
||||
let sawHotp = false;
|
||||
let sawMigration = false;
|
||||
for (const payload of payloads) {
|
||||
const parsed = parseTotpInput(payload);
|
||||
if (parsed.ok) return { ok: true, uri: parsed.uri, parsed };
|
||||
if (parsed.error === "hotp") sawHotp = true;
|
||||
else if (parsed.error === "migration") sawMigration = true;
|
||||
}
|
||||
if (sawHotp) return { ok: false, error: "hotp" };
|
||||
if (sawMigration) return { ok: false, error: "migration" };
|
||||
return { ok: false, error: "not-found" };
|
||||
}
|
||||
|
||||
function bgraToRgba(bitmap, width, height) {
|
||||
const rgba = new Uint8ClampedArray(width * height * 4);
|
||||
const source = bitmap instanceof Uint8Array ? bitmap : new Uint8Array(bitmap);
|
||||
for (let index = 0; index < width * height; index += 1) {
|
||||
const offset = index * 4;
|
||||
rgba[offset] = source[offset + 2];
|
||||
rgba[offset + 1] = source[offset + 1];
|
||||
rgba[offset + 2] = source[offset];
|
||||
rgba[offset + 3] = source[offset + 3];
|
||||
}
|
||||
return rgba;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
NON_TOTP_PROMPT,
|
||||
decodeBase32,
|
||||
encodeBase32,
|
||||
generateTotp,
|
||||
parseTotpInput,
|
||||
currentTotp,
|
||||
planTotpSubmission,
|
||||
previewTotpText,
|
||||
isAutoSubmittableTotpPrompt,
|
||||
planTotpAutoSubmit,
|
||||
selectTotpFromQrPayloads,
|
||||
bgraToRgba,
|
||||
};
|
||||
@@ -0,0 +1,231 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { createRequire } from "node:module";
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
const {
|
||||
generateTotp,
|
||||
parseTotpInput,
|
||||
isAutoSubmittableTotpPrompt,
|
||||
planTotpSubmission,
|
||||
selectTotpFromQrPayloads,
|
||||
encodeBase32,
|
||||
} = require("./totp.shared.cjs") as {
|
||||
generateTotp: (input: {
|
||||
secret: Buffer;
|
||||
algorithm: string;
|
||||
digits: number;
|
||||
period: number;
|
||||
timestamp: number;
|
||||
}) => string;
|
||||
parseTotpInput: (input: string) => {
|
||||
ok: boolean;
|
||||
error?: string;
|
||||
uri?: string;
|
||||
algorithm?: string;
|
||||
digits?: number;
|
||||
period?: number;
|
||||
issuer?: string;
|
||||
label?: string;
|
||||
secret?: Buffer;
|
||||
};
|
||||
isAutoSubmittableTotpPrompt: (input: {
|
||||
prompts?: Array<{ prompt?: string }>;
|
||||
name?: string;
|
||||
instructions?: string;
|
||||
promptPattern?: string;
|
||||
}) => boolean;
|
||||
planTotpSubmission: (parsed: { secret: Buffer; algorithm: string; digits: number; period: number }, timestamp: number) => {
|
||||
code: string;
|
||||
delayMs: number;
|
||||
};
|
||||
selectTotpFromQrPayloads: (payloads: string[]) => { ok: boolean; error?: string; uri?: string };
|
||||
encodeBase32: (buffer: Buffer) => string;
|
||||
};
|
||||
|
||||
const SHA1_SECRET = Buffer.from("12345678901234567890", "ascii");
|
||||
const SHA256_SECRET = Buffer.from("12345678901234567890123456789012", "ascii");
|
||||
const SHA512_SECRET = Buffer.from(
|
||||
"1234567890123456789012345678901234567890123456789012345678901234",
|
||||
"ascii",
|
||||
);
|
||||
|
||||
const RFC_VECTORS: Array<[number, string, string, string]> = [
|
||||
[59, "94287082", "46119246", "90693936"],
|
||||
[1111111109, "07081804", "68084774", "25091201"],
|
||||
[1111111111, "14050471", "67062674", "99943326"],
|
||||
[1234567890, "89005924", "91819424", "93441116"],
|
||||
[2000000000, "69279037", "90698825", "38618901"],
|
||||
[20000000000, "65353130", "77737706", "47863826"],
|
||||
];
|
||||
|
||||
for (const [time, sha1, sha256, sha512] of RFC_VECTORS) {
|
||||
test(`RFC 6238 TOTP at ${time}`, () => {
|
||||
const timestamp = time * 1000;
|
||||
assert.equal(generateTotp({ secret: SHA1_SECRET, algorithm: "SHA1", digits: 8, period: 30, timestamp }), sha1);
|
||||
assert.equal(generateTotp({ secret: SHA256_SECRET, algorithm: "SHA256", digits: 8, period: 30, timestamp }), sha256);
|
||||
assert.equal(generateTotp({ secret: SHA512_SECRET, algorithm: "SHA512", digits: 8, period: 30, timestamp }), sha512);
|
||||
});
|
||||
}
|
||||
|
||||
test("parseTotpInput keeps algorithm, digits, period, issuer, and label", () => {
|
||||
const parsed = parseTotpInput(
|
||||
"otpauth://totp/Example:alice%40example.com?secret=gezdgnbvg y3tqojqgezdgnbvgy3tqojq&issuer=Example&algorithm=SHA256&digits=7&period=45",
|
||||
);
|
||||
assert.equal(parsed.ok, true);
|
||||
assert.equal(parsed.algorithm, "SHA256");
|
||||
assert.equal(parsed.digits, 7);
|
||||
assert.equal(parsed.period, 45);
|
||||
assert.equal(parsed.issuer, "Example");
|
||||
assert.equal(parsed.label, "alice@example.com");
|
||||
assert.equal(parsed.secret?.equals(SHA1_SECRET), true);
|
||||
assert.match(parsed.uri || "", /^otpauth:\/\/totp\//);
|
||||
assert.match(parsed.uri || "", /algorithm=SHA256/);
|
||||
assert.match(parsed.uri || "", /digits=7/);
|
||||
assert.match(parsed.uri || "", /period=45/);
|
||||
assert.match(parsed.uri || "", /issuer=Example/);
|
||||
});
|
||||
|
||||
test("parseTotpInput accepts a raw Base32 secret with SHA1/6/30 defaults", () => {
|
||||
const parsed = parseTotpInput(` ${encodeBase32(SHA1_SECRET).toLowerCase()} `);
|
||||
assert.equal(parsed.ok, true);
|
||||
assert.equal(parsed.algorithm, "SHA1");
|
||||
assert.equal(parsed.digits, 6);
|
||||
assert.equal(parsed.period, 30);
|
||||
assert.equal(parsed.secret?.equals(SHA1_SECRET), true);
|
||||
});
|
||||
|
||||
test("parseTotpInput accepts SHA-1 spelling and uppercase type", () => {
|
||||
const parsed = parseTotpInput(
|
||||
`OTPAUTH://TOTP/JumpServer?secret=${encodeBase32(SHA1_SECRET)}&algorithm=SHA-1`,
|
||||
);
|
||||
assert.equal(parsed.ok, true);
|
||||
assert.equal(parsed.algorithm, "SHA1");
|
||||
assert.equal(parsed.issuer, "");
|
||||
assert.equal(parsed.label, "JumpServer");
|
||||
});
|
||||
|
||||
test("parseTotpInput splits issuer out of the label when the query omits it", () => {
|
||||
const parsed = parseTotpInput(
|
||||
`otpauth://totp/JumpServer:root?secret=${encodeBase32(SHA1_SECRET)}`,
|
||||
);
|
||||
assert.equal(parsed.ok, true);
|
||||
assert.equal(parsed.issuer, "JumpServer");
|
||||
assert.equal(parsed.label, "root");
|
||||
});
|
||||
|
||||
test("parseTotpInput rejects HOTP, migration QR codes, and bad parameters", () => {
|
||||
assert.equal(parseTotpInput("otpauth://hotp/Example:alice?secret=GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ&counter=1").error, "hotp");
|
||||
assert.equal(parseTotpInput("otpauth-migration://offline?data=abc").error, "migration");
|
||||
assert.equal(parseTotpInput("").error, "empty");
|
||||
assert.equal(parseTotpInput("otpauth://totp/Example:alice").error, "missing-secret");
|
||||
assert.equal(parseTotpInput("otpauth://push/Example:alice?secret=GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ").error, "unsupported-type");
|
||||
assert.equal(parseTotpInput("not a secret").error, "invalid-secret");
|
||||
assert.equal(parseTotpInput("AAAA").error, "invalid-secret");
|
||||
assert.equal(parseTotpInput(`otpauth://totp/A?secret=${encodeBase32(SHA1_SECRET)}&algorithm=SHA384`).error, "invalid-algorithm");
|
||||
assert.equal(parseTotpInput(`otpauth://totp/A?secret=${encodeBase32(SHA1_SECRET)}&digits=5`).error, "invalid-digits");
|
||||
assert.equal(parseTotpInput(`otpauth://totp/A?secret=${encodeBase32(SHA1_SECRET)}&digits=9`).error, "invalid-digits");
|
||||
assert.equal(parseTotpInput(`otpauth://totp/A?secret=${encodeBase32(SHA1_SECRET)}&period=0`).error, "invalid-period");
|
||||
assert.equal(parseTotpInput(`otpauth://totp/A?secret=${encodeBase32(SHA1_SECRET)}&period=301`).error, "invalid-period");
|
||||
assert.equal(parseTotpInput("otpauth:").error, "invalid-uri");
|
||||
});
|
||||
|
||||
test("auto-submit waits out the last two seconds of the current window", () => {
|
||||
const parsed = parseTotpInput(`otpauth://totp/A?secret=${encodeBase32(SHA1_SECRET)}&digits=8&period=30`);
|
||||
assert.equal(parsed.ok, true);
|
||||
const soon = planTotpSubmission(parsed as { secret: Buffer; algorithm: string; digits: number; period: number }, 59_000);
|
||||
assert.equal(soon.delayMs, 1000);
|
||||
assert.equal(soon.code, generateTotp({
|
||||
secret: SHA1_SECRET,
|
||||
algorithm: "SHA1",
|
||||
digits: 8,
|
||||
period: 30,
|
||||
timestamp: 60_000,
|
||||
}));
|
||||
const early = planTotpSubmission(parsed as { secret: Buffer; algorithm: string; digits: number; period: number }, 15_000);
|
||||
assert.equal(early.delayMs, 0);
|
||||
});
|
||||
|
||||
const prompt = (text: string) => ({ prompts: [{ prompt: text }] });
|
||||
|
||||
test("prompt matcher accepts JumpServer and PAM TOTP prompts", () => {
|
||||
assert.equal(isAutoSubmittableTotpPrompt({
|
||||
...prompt("[OTP Code]: "),
|
||||
instructions: "Please Enter MFA Code.",
|
||||
}), true);
|
||||
assert.equal(isAutoSubmittableTotpPrompt(prompt("[OTP Code]:")), true);
|
||||
assert.equal(isAutoSubmittableTotpPrompt({
|
||||
...prompt("[MFA auth]: "),
|
||||
instructions: "Please enter 6 digits.",
|
||||
}), true);
|
||||
assert.equal(isAutoSubmittableTotpPrompt(prompt("Verification code: ")), true);
|
||||
assert.equal(isAutoSubmittableTotpPrompt(prompt("Verification code:")), true);
|
||||
});
|
||||
|
||||
test("prompt matcher rejects SMS, email, option menus, and confirmations", () => {
|
||||
assert.equal(isAutoSubmittableTotpPrompt(prompt("[SMS Code]: ")), false);
|
||||
assert.equal(isAutoSubmittableTotpPrompt(prompt("[EMAIL Code]: ")), false);
|
||||
assert.equal(isAutoSubmittableTotpPrompt(prompt("[OTP_RADIUS Code]: ")), false);
|
||||
assert.equal(isAutoSubmittableTotpPrompt({
|
||||
prompts: [{ prompt: "\n1. OTP\n2. SMS\nOption> " }],
|
||||
instructions: "Please Select MFA Option",
|
||||
}), false);
|
||||
assert.equal(isAutoSubmittableTotpPrompt(prompt("Do you want to continue [Y/n]? : ")), false);
|
||||
assert.equal(isAutoSubmittableTotpPrompt(prompt("Password:")), false);
|
||||
assert.equal(isAutoSubmittableTotpPrompt({
|
||||
prompts: [{ prompt: "Password:" }, { prompt: "[OTP Code]: " }],
|
||||
}), false);
|
||||
});
|
||||
|
||||
test("a host regex can add a prompt without overriding the exclusion list", () => {
|
||||
assert.equal(isAutoSubmittableTotpPrompt({
|
||||
...prompt("My OTP: "),
|
||||
promptPattern: "^My OTP:\\s*$",
|
||||
}), true);
|
||||
assert.equal(isAutoSubmittableTotpPrompt({
|
||||
...prompt("[OTP Code]: "),
|
||||
promptPattern: "^My OTP:\\s*$",
|
||||
}), true);
|
||||
assert.equal(isAutoSubmittableTotpPrompt({
|
||||
...prompt("[SMS Code]: "),
|
||||
promptPattern: ".*",
|
||||
}), false);
|
||||
assert.equal(isAutoSubmittableTotpPrompt({
|
||||
...prompt("Option> "),
|
||||
promptPattern: ".*",
|
||||
}), false);
|
||||
assert.equal(isAutoSubmittableTotpPrompt({
|
||||
...prompt("[OTP Code]: "),
|
||||
promptPattern: "(",
|
||||
}), true);
|
||||
});
|
||||
|
||||
test("custom prompt patterns stay linear and ignore syntax RE2 rejects", () => {
|
||||
const started = Date.now();
|
||||
assert.equal(isAutoSubmittableTotpPrompt({
|
||||
prompts: [{ prompt: `${"a".repeat(32)}!` }],
|
||||
promptPattern: "(a+)+$",
|
||||
}), false);
|
||||
assert.ok(Date.now() - started < 1000);
|
||||
assert.equal(isAutoSubmittableTotpPrompt({
|
||||
prompts: [{ prompt: "x".repeat(2048) }],
|
||||
promptPattern: "x+",
|
||||
}), false);
|
||||
assert.equal(isAutoSubmittableTotpPrompt({
|
||||
...prompt("My OTP: "),
|
||||
promptPattern: "^(a)\\1$",
|
||||
}), false);
|
||||
});
|
||||
|
||||
test("QR payload selection keeps the first totp URI and rejects HOTP or migration", () => {
|
||||
const totpUri = `otpauth://totp/Example:alice?secret=${encodeBase32(SHA1_SECRET)}`;
|
||||
const selected = selectTotpFromQrPayloads([
|
||||
"otpauth://hotp/Example:alice?secret=GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ&counter=1",
|
||||
totpUri,
|
||||
]);
|
||||
assert.equal(selected.ok, true);
|
||||
assert.match(selected.uri || "", /^otpauth:\/\/totp\//);
|
||||
assert.equal(selectTotpFromQrPayloads(["otpauth://hotp/A?secret=GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ&counter=1"]).error, "hotp");
|
||||
assert.equal(selectTotpFromQrPayloads(["otpauth-migration://offline?data=abc"]).error, "migration");
|
||||
assert.equal(selectTotpFromQrPayloads(["hello"]).error, "not-found");
|
||||
});
|
||||
@@ -0,0 +1,24 @@
|
||||
import type { HostTotpConfig } from "./models";
|
||||
import { isEncryptedCredentialPlaceholder } from "./credentials";
|
||||
|
||||
/** Connection payload for a host TOTP secret. Device-bound ciphertext is omitted. */
|
||||
export function connectionTotpFromHost(
|
||||
host: { totp?: HostTotpConfig } | null | undefined,
|
||||
): HostTotpConfig | undefined {
|
||||
const totp = host?.totp;
|
||||
const uri = totp?.uri?.trim();
|
||||
if (!uri || isEncryptedCredentialPlaceholder(uri)) return undefined;
|
||||
const promptPattern = totp?.promptPattern?.trim();
|
||||
return {
|
||||
uri,
|
||||
autoSubmit: totp?.autoSubmit !== false,
|
||||
...(promptPattern ? { promptPattern } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
export function connectionTotpFields(
|
||||
host: { totp?: HostTotpConfig } | null | undefined,
|
||||
): { totp?: HostTotpConfig } {
|
||||
const totp = connectionTotpFromHost(host);
|
||||
return totp ? { totp } : {};
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
"use strict";
|
||||
|
||||
// SMS, email, RADIUS, menus, and confirmations must never be treated as a
|
||||
// TOTP code prompt, even when earlier lines mention OTP.
|
||||
// Assigned on `exports` so the renderer can import the name through Vite.
|
||||
const NON_TOTP_PROMPT = /SMS|EMAIL|RADIUS|PASSKEY|MFA_CUSTOM|短信|邮件|郵件|Option>|\[Y\/n\]/i;
|
||||
exports.NON_TOTP_PROMPT = NON_TOTP_PROMPT;
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
export const NON_TOTP_PROMPT: RegExp;
|
||||
@@ -0,0 +1,4 @@
|
||||
// SMS, email, RADIUS, menus, and confirmations must never be treated as a
|
||||
// TOTP code prompt, even when earlier lines mention OTP.
|
||||
// Keep this pattern identical to domain/totpPrompt.shared.cjs.
|
||||
export const NON_TOTP_PROMPT = /SMS|EMAIL|RADIUS|PASSKEY|MFA_CUSTOM|短信|邮件|郵件|Option>|\[Y\/n\]/i;
|
||||
@@ -73,6 +73,10 @@ module.exports = {
|
||||
'domain/singleChannelSshBanner.shared.cjs',
|
||||
'domain/singleChannelSshBanner.shared.mjs',
|
||||
'domain/sshServerVendor.mjs',
|
||||
// sshAuthHelper.cjs requires this at load. TypeScript under domain/
|
||||
// is excluded below, so the shared TOTP runtime must be listed.
|
||||
'domain/totp.shared.cjs',
|
||||
'domain/totpPrompt.shared.cjs',
|
||||
'lib/**/*.cjs',
|
||||
'lib/**/*.json',
|
||||
'skills/**/*',
|
||||
@@ -112,7 +116,8 @@ module.exports = {
|
||||
'!node_modules/@streamdown/**/*',
|
||||
'!node_modules/@tanstack/react-virtual/**/*',
|
||||
'!node_modules/pinyin-pro/**/*',
|
||||
'!node_modules/re2js/**/*',
|
||||
// re2js stays in the package: the main-process TOTP prompt matcher
|
||||
// requires it so custom patterns cannot freeze Electron.
|
||||
'!node_modules/@eslint-community/regexpp/**/*',
|
||||
'!node_modules/clsx/**/*',
|
||||
'!node_modules/tailwind-merge/**/*',
|
||||
|
||||
@@ -552,7 +552,7 @@ function createStopWait() {
|
||||
};
|
||||
}
|
||||
|
||||
async function runOpenCodeTurn({
|
||||
async function runOpenCodeV1Turn({
|
||||
prompt, systemPrompt, attachments, cwd, model, injectedMcpServers, toolIntegrationMode,
|
||||
skillsPathAllowlist, resumeSessionId, env, binPath, emitter, abortController, openCodeFactory,
|
||||
}) {
|
||||
@@ -937,10 +937,21 @@ function resetOpenCodeListServerPool() {
|
||||
disposeOpenCodeListServer(key, entry);
|
||||
}
|
||||
openCodeListServers.clear();
|
||||
require("./opencodeV2.cjs").resetOpenCodeV2ListServerPool();
|
||||
}
|
||||
|
||||
async function listOpenCodeModels({ env, binPath, openCodeFactory, abortController, signal } = {}) {
|
||||
async function runOpenCodeTurn(args) {
|
||||
const { shouldUseOpenCodeV2, runOpenCodeV2Turn } = require("./opencodeV2.cjs");
|
||||
if (await shouldUseOpenCodeV2(args)) return runOpenCodeV2Turn(args);
|
||||
return runOpenCodeV1Turn(args);
|
||||
}
|
||||
|
||||
async function listOpenCodeModels({ env, binPath, openCodeFactory, abortController, signal, ...rest } = {}) {
|
||||
const effectiveSignal = signal || abortController?.signal;
|
||||
if (effectiveSignal?.aborted) return emptyOpenCodeModelCatalog();
|
||||
const { shouldUseOpenCodeV2, listOpenCodeV2Models } = require("./opencodeV2.cjs");
|
||||
const selection = { env, binPath, openCodeFactory, abortController, signal, ...rest };
|
||||
if (await shouldUseOpenCodeV2(selection)) return listOpenCodeV2Models(selection);
|
||||
let acquired = null;
|
||||
try {
|
||||
if (effectiveSignal?.aborted) return emptyOpenCodeModelCatalog();
|
||||
|
||||
@@ -24,6 +24,10 @@ const fs = require('node:fs');
|
||||
const http = require('node:http');
|
||||
const path = require('node:path');
|
||||
const root = ${JSON.stringify(root)};
|
||||
if (process.argv.includes('--version')) {
|
||||
console.log('1.19.3');
|
||||
process.exit(0);
|
||||
}
|
||||
const profile = path.basename(process.env.HOME);
|
||||
fs.appendFileSync(path.join(root, 'started'), profile + '\\n');
|
||||
if (profile === 'profile-failed') process.exit(7);
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,954 @@
|
||||
const test = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const fs = require("node:fs");
|
||||
const os = require("node:os");
|
||||
const path = require("node:path");
|
||||
const { EventEmitter } = require("node:events");
|
||||
const {
|
||||
buildOpenCodeBasicAuthorization,
|
||||
buildOpenCodeV2ConfigContent,
|
||||
mergeOpenCodeV2ConfigContent,
|
||||
createOpenCodeServeStartupParser,
|
||||
createOpenCodeV2Client,
|
||||
detectOpenCodeCliVersion,
|
||||
isOpenCodeV2Version,
|
||||
listOpenCodeV2Models,
|
||||
mapOpenCodeV2Catalog,
|
||||
parseOpenCodeVersionOutput,
|
||||
resetOpenCodeVersionCache,
|
||||
runOpenCodeV2Turn,
|
||||
translateOpenCodeV2Event,
|
||||
waitForOpenCodeChildReady,
|
||||
} = require("./opencodeV2.cjs");
|
||||
const { listOpenCodeModels, resetOpenCodeListServerPool, runOpenCodeTurn } = require("./opencodeDriver.cjs");
|
||||
|
||||
test("parseOpenCodeVersionOutput distinguishes OpenCode 1.x from 2.x", () => {
|
||||
assert.deepEqual(parseOpenCodeVersionOutput("v2.1.0\n"), { major: 2, minor: 1, patch: 0 });
|
||||
assert.deepEqual(parseOpenCodeVersionOutput("2.1.0"), { major: 2, minor: 1, patch: 0 });
|
||||
assert.deepEqual(parseOpenCodeVersionOutput("opencode version 1.19.3"), { major: 1, minor: 19, patch: 3 });
|
||||
assert.equal(parseOpenCodeVersionOutput("v2.0.0-beta.1").major, 2);
|
||||
assert.equal(parseOpenCodeVersionOutput(""), null);
|
||||
assert.equal(parseOpenCodeVersionOutput("not a version"), null);
|
||||
assert.equal(parseOpenCodeVersionOutput("listening on 127.0.0.1:4096"), null);
|
||||
assert.equal(
|
||||
parseOpenCodeVersionOutput("TypeError: Cannot read properties of undefined (reading 'split')\nNode.js v22.23.1\n"),
|
||||
null,
|
||||
);
|
||||
assert.equal(parseOpenCodeVersionOutput("panic: failed\nBun v1.3.14 (linux x64)\n"), null);
|
||||
assert.deepEqual(parseOpenCodeVersionOutput("v2.1.0\nNode.js v22.23.1\n"), { major: 2, minor: 1, patch: 0 });
|
||||
assert.deepEqual(parseOpenCodeVersionOutput("v2.1.0\nBun v1.3.14\n"), { major: 2, minor: 1, patch: 0 });
|
||||
assert.equal(isOpenCodeV2Version(parseOpenCodeVersionOutput("v2.1.0")), true);
|
||||
assert.equal(isOpenCodeV2Version(parseOpenCodeVersionOutput("1.19.3")), false);
|
||||
assert.equal(isOpenCodeV2Version(null), false);
|
||||
});
|
||||
|
||||
test("version cache follows the PATH binary when OpenCode is replaced", async () => {
|
||||
resetOpenCodeVersionCache();
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "opencode-version-cache-"));
|
||||
const bin = path.join(dir, "opencode");
|
||||
fs.writeFileSync(bin, "#!/bin/sh\necho v1.19.3\n", { mode: 0o755 });
|
||||
try {
|
||||
const env = { PATH: `${dir}${path.delimiter}${process.env.PATH || ""}` };
|
||||
const first = await detectOpenCodeCliVersion({ env });
|
||||
assert.equal(first.major, 1);
|
||||
fs.writeFileSync(bin, "#!/bin/sh\necho v2.1.0\n", { mode: 0o755 });
|
||||
const replacedAt = new Date(Date.now() + 2000);
|
||||
fs.utimesSync(bin, replacedAt, replacedAt);
|
||||
const replaced = await detectOpenCodeCliVersion({ env });
|
||||
assert.equal(replaced.major, 2);
|
||||
} finally {
|
||||
resetOpenCodeVersionCache();
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("a crashed --version probe is not classified as OpenCode 2", async () => {
|
||||
resetOpenCodeVersionCache();
|
||||
const crash = "TypeError: Cannot read properties of undefined (reading 'split')\nNode.js v22.23.1\n";
|
||||
const version = await detectOpenCodeCliVersion({
|
||||
binPath: "opencode",
|
||||
execFileImpl: (_command, _args, _options, callback) => {
|
||||
const error = new Error("opencode --version failed");
|
||||
error.code = 1;
|
||||
callback(error, "", crash);
|
||||
},
|
||||
});
|
||||
assert.equal(version, null);
|
||||
assert.equal(isOpenCodeV2Version(version), false);
|
||||
});
|
||||
|
||||
test("a Bun crash banner is not cached as OpenCode 1.x", async () => {
|
||||
resetOpenCodeVersionCache();
|
||||
let calls = 0;
|
||||
const probe = () => detectOpenCodeCliVersion({
|
||||
binPath: "opencode",
|
||||
execFileImpl: (_command, _args, _options, callback) => {
|
||||
calls += 1;
|
||||
if (calls === 1) {
|
||||
const error = new Error("opencode --version failed");
|
||||
error.code = 1;
|
||||
callback(error, "", "panic: failed\nBun v1.3.14 (linux x64)\n");
|
||||
return;
|
||||
}
|
||||
callback(null, "v2.1.0\n", "");
|
||||
},
|
||||
});
|
||||
assert.equal(await probe(), null);
|
||||
assert.equal((await probe()).major, 2);
|
||||
assert.equal(calls, 2);
|
||||
});
|
||||
|
||||
test("OpenCode serve startup matches the 2.x banner and a split password line", () => {
|
||||
const parser = createOpenCodeServeStartupParser();
|
||||
assert.deepEqual(parser.push("server listen"), { url: null, password: null, ready: false });
|
||||
assert.deepEqual(
|
||||
parser.push("ing on http://127.0.0.1:4455\nser"),
|
||||
{ url: "http://127.0.0.1:4455", password: null, ready: true },
|
||||
);
|
||||
assert.deepEqual(
|
||||
parser.push("ver password zNAK.secret\n"),
|
||||
{ url: "http://127.0.0.1:4455", password: "zNAK.secret", ready: true },
|
||||
);
|
||||
|
||||
const v1 = createOpenCodeServeStartupParser();
|
||||
assert.deepEqual(
|
||||
v1.push("opencode server listening on http://127.0.0.1:4096\n"),
|
||||
{ url: "http://127.0.0.1:4096", password: null, ready: true },
|
||||
);
|
||||
});
|
||||
|
||||
test("startup waiter captures a late password and never returns server spam", async () => {
|
||||
const child = new EventEmitter();
|
||||
child.stdout = new EventEmitter();
|
||||
child.stderr = new EventEmitter();
|
||||
child.pid = 42;
|
||||
let stopped = 0;
|
||||
const ready = waitForOpenCodeChildReady(child, {
|
||||
timeoutMs: 1000,
|
||||
passwordGraceMs: 30,
|
||||
stop: () => { stopped += 1; },
|
||||
});
|
||||
child.stdout.emit("data", Buffer.from("server listening on http://127.0.0.1:4096\n"));
|
||||
child.stderr.emit("data", Buffer.from("server password printed-secret\n"));
|
||||
const result = await ready;
|
||||
assert.equal(result.url, "http://127.0.0.1:4096");
|
||||
assert.equal(result.password, "printed-secret");
|
||||
assert.equal(stopped, 0);
|
||||
|
||||
const timedOut = new EventEmitter();
|
||||
timedOut.stdout = new EventEmitter();
|
||||
timedOut.stderr = new EventEmitter();
|
||||
timedOut.pid = 43;
|
||||
const pending = waitForOpenCodeChildReady(timedOut, {
|
||||
timeoutMs: 20,
|
||||
passwordGraceMs: 500,
|
||||
stop: () => {},
|
||||
});
|
||||
timedOut.stderr.emit("data", Buffer.from("debug server password hunter2\nstill starting\n"));
|
||||
await assert.rejects(pending, (error) => {
|
||||
assert.equal(error.message, "ai.opencode.v2.startupTimeout");
|
||||
assert.equal(JSON.stringify(error).includes("hunter2"), false);
|
||||
return true;
|
||||
});
|
||||
});
|
||||
|
||||
test("startup waiter reports an exit without the server log", async () => {
|
||||
const child = new EventEmitter();
|
||||
child.stdout = new EventEmitter();
|
||||
child.stderr = new EventEmitter();
|
||||
const pending = waitForOpenCodeChildReady(child, { timeoutMs: 1000, passwordGraceMs: 20, stop: () => {} });
|
||||
child.stderr.emit("data", Buffer.from("server password hunter2\npanic\n"));
|
||||
child.emit("exit", 7);
|
||||
await assert.rejects(pending, (error) => {
|
||||
assert.equal(error.message, "ai.opencode.v2.exited");
|
||||
assert.equal(JSON.stringify(error).includes("hunter2"), false);
|
||||
assert.equal(JSON.stringify(error).includes("panic"), false);
|
||||
return true;
|
||||
});
|
||||
});
|
||||
|
||||
test("startup waiter resolves with the injected password when 2.x omits the password line", async () => {
|
||||
const child = new EventEmitter();
|
||||
child.stdout = new EventEmitter();
|
||||
child.stderr = new EventEmitter();
|
||||
const ready = waitForOpenCodeChildReady(child, { timeoutMs: 1000, passwordGraceMs: 20, stop: () => {} });
|
||||
child.stdout.emit("data", Buffer.from("server listening on http://127.0.0.1:4096\n"));
|
||||
const result = await ready;
|
||||
assert.equal(result.url, "http://127.0.0.1:4096");
|
||||
assert.equal(result.password, null);
|
||||
});
|
||||
|
||||
test("2.x config keeps permissions and local MCP without the 1.x startup keys", () => {
|
||||
const content = buildOpenCodeV2ConfigContent({
|
||||
share: "disabled",
|
||||
autoupdate: false,
|
||||
permission: { edit: "deny", bash: "allow", read: { "*": "deny", "/tmp/**": "allow" } },
|
||||
mcp: {
|
||||
netcatty: { type: "local", command: ["node", "cli.js"], environment: { A: "b" }, enabled: true },
|
||||
},
|
||||
model: "openai/gpt-5",
|
||||
});
|
||||
assert.equal(content.share, "disabled");
|
||||
assert.equal(content.update, "disable");
|
||||
assert.equal(content.permission, undefined);
|
||||
assert.equal(content.autoupdate, undefined);
|
||||
assert.deepEqual(content.permissions, [
|
||||
{ action: "edit", resource: "*", effect: "deny" },
|
||||
{ action: "bash", resource: "*", effect: "allow" },
|
||||
{ action: "read", resource: "*", effect: "deny" },
|
||||
{ action: "read", resource: "/tmp/**", effect: "allow" },
|
||||
]);
|
||||
assert.deepEqual(content.mcp.servers.netcatty, {
|
||||
type: "local",
|
||||
command: ["node", "cli.js"],
|
||||
environment: { A: "b" },
|
||||
disabled: false,
|
||||
});
|
||||
assert.equal(content.model, "openai/gpt-5");
|
||||
});
|
||||
|
||||
test("2.x config keeps caller providers and plugins", () => {
|
||||
const generated = buildOpenCodeV2ConfigContent({
|
||||
share: "disabled",
|
||||
autoupdate: false,
|
||||
model: "openai/gpt-5",
|
||||
mcp: { netcatty: { type: "local", command: ["node", "cli.js"], environment: {}, enabled: true } },
|
||||
});
|
||||
const merged = mergeOpenCodeV2ConfigContent(generated, JSON.stringify({
|
||||
username: "other",
|
||||
permission: { edit: "allow" },
|
||||
autoupdate: true,
|
||||
model: "anthropic/claude",
|
||||
providers: { local: { models: { coder: { modelID: "real" } } } },
|
||||
plugins: ["opencode-provider"],
|
||||
mcp: { servers: { extra: { type: "local", command: ["echo"] } } },
|
||||
}));
|
||||
assert.equal(merged.model, "openai/gpt-5");
|
||||
assert.equal(merged.username, undefined);
|
||||
assert.equal(merged.permission, undefined);
|
||||
assert.equal(merged.autoupdate, undefined);
|
||||
assert.deepEqual(merged.providers.local.models.coder, { modelID: "real" });
|
||||
assert.deepEqual(merged.plugins, ["opencode-provider"]);
|
||||
assert.deepEqual(merged.mcp.servers.extra, { type: "local", command: ["echo"] });
|
||||
assert.equal(merged.mcp.servers.netcatty.type, "local");
|
||||
assert.equal(mergeOpenCodeV2ConfigContent(generated, "not json"), generated);
|
||||
});
|
||||
|
||||
test("mapOpenCodeV2Catalog uses provider and model ids from the 2.x payloads", () => {
|
||||
const catalog = mapOpenCodeV2Catalog(
|
||||
{ data: [
|
||||
{ providerID: "openai", modelID: "gpt-5", name: "GPT-5", enabled: true },
|
||||
{ providerID: "openai", modelID: "hidden", name: "Hidden", enabled: false },
|
||||
] },
|
||||
{ data: [{ id: "openai", name: "OpenAI" }] },
|
||||
{ data: { providerID: "openai", modelID: "gpt-5", name: "GPT-5" } },
|
||||
);
|
||||
assert.deepEqual(catalog, {
|
||||
currentModelId: "openai/gpt-5",
|
||||
models: [{ id: "openai/gpt-5", name: "OpenAI GPT-5" }],
|
||||
});
|
||||
|
||||
const aliased = mapOpenCodeV2Catalog(
|
||||
{ data: [
|
||||
{ id: "coding-default", modelID: "gpt-5.2", providerID: "openai", name: "Coding default", enabled: true },
|
||||
{ id: "anthropic/claude-sonnet-4.5", modelID: "claude-sonnet-4.5", providerID: "openrouter", name: "Sonnet", enabled: true },
|
||||
] },
|
||||
{ data: [{ id: "openai", name: "OpenAI" }, { id: "openrouter", name: "OpenRouter" }] },
|
||||
{ data: { id: "coding-default", modelID: "gpt-5.2", providerID: "openai", name: "Coding default" } },
|
||||
);
|
||||
assert.deepEqual(aliased, {
|
||||
currentModelId: "openai/coding-default",
|
||||
models: [
|
||||
{ id: "openai/coding-default", name: "OpenAI Coding default" },
|
||||
{ id: "openrouter/anthropic/claude-sonnet-4.5", name: "OpenRouter Sonnet" },
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
test("OpenCode 2 client sends Basic auth and hides unauthorized bodies", async () => {
|
||||
const requests = [];
|
||||
const password = "s3cret-value";
|
||||
const fetchImpl = async (url, init) => {
|
||||
requests.push({ url: String(url), init });
|
||||
if (String(url).endsWith("/api/info")) {
|
||||
return new Response(`<html>server password ${password}</html>`, { status: 401, headers: { "content-type": "text/html" } });
|
||||
}
|
||||
if (String(url).includes("/api/model/default")) {
|
||||
return jsonResponse({ data: { providerID: "openai", modelID: "gpt-5", name: "GPT-5" } });
|
||||
}
|
||||
if (String(url).includes("/api/provider")) {
|
||||
return jsonResponse({ data: [{ id: "openai", name: "OpenAI" }] });
|
||||
}
|
||||
return jsonResponse({ data: [{ providerID: "openai", modelID: "gpt-5", name: "GPT-5", enabled: true }] });
|
||||
};
|
||||
const client = createOpenCodeV2Client({
|
||||
baseUrl: "http://127.0.0.1:4096",
|
||||
username: "opencode",
|
||||
password,
|
||||
fetchImpl,
|
||||
});
|
||||
assert.equal(client.authorization, buildOpenCodeBasicAuthorization("opencode", password));
|
||||
|
||||
const catalog = await client.listCatalog(undefined, "/workspace");
|
||||
assert.equal(catalog.currentModelId, "openai/gpt-5");
|
||||
assert.equal(requests.length, 3);
|
||||
for (const request of requests) {
|
||||
assert.equal(request.init.headers.Authorization, client.authorization);
|
||||
assert.equal(request.init.headers.authorization, undefined);
|
||||
}
|
||||
const modelUrl = new URL(requests.find((request) => request.url.includes("/api/model") && !request.url.includes("default")).url);
|
||||
assert.equal(modelUrl.searchParams.get("location[directory]"), "/workspace");
|
||||
|
||||
await assert.rejects(client.info(), (error) => {
|
||||
assert.equal(error.message, "ai.opencode.v2.unauthorized");
|
||||
assert.equal(JSON.stringify(error).includes(password), false);
|
||||
assert.equal(JSON.stringify(error).includes("<html>"), false);
|
||||
return true;
|
||||
});
|
||||
|
||||
const implicit = createOpenCodeV2Client({
|
||||
baseUrl: "http://127.0.0.1:4096",
|
||||
password,
|
||||
fetchImpl: async () => jsonResponse({ data: {} }),
|
||||
});
|
||||
assert.equal(implicit.authorization, buildOpenCodeBasicAuthorization("opencode", password));
|
||||
});
|
||||
|
||||
test("OpenCode 2 client hides undici transport failures", async () => {
|
||||
const cause = Object.assign(new Error("other side closed"), { code: "UND_ERR_SOCKET" });
|
||||
const fetchFailed = new TypeError("fetch failed");
|
||||
fetchFailed.cause = cause;
|
||||
const failing = createOpenCodeV2Client({
|
||||
baseUrl: "http://127.0.0.1:4096",
|
||||
username: "opencode",
|
||||
password: "s3cret",
|
||||
fetchImpl: async () => { throw fetchFailed; },
|
||||
});
|
||||
await assert.rejects(failing.info(), (error) => {
|
||||
assert.equal(error.message, "ai.opencode.v2.unavailable");
|
||||
assert.equal(String(error).includes("fetch failed"), false);
|
||||
assert.equal(String(error).includes("other side closed"), false);
|
||||
return true;
|
||||
});
|
||||
|
||||
const dropped = createOpenCodeV2Client({
|
||||
baseUrl: "http://127.0.0.1:4096",
|
||||
username: "opencode",
|
||||
password: "s3cret",
|
||||
fetchImpl: async () => ({
|
||||
status: 200,
|
||||
body: { cancel() {} },
|
||||
text: async () => { throw new TypeError("terminated"); },
|
||||
}),
|
||||
});
|
||||
await assert.rejects(dropped.info(), (error) => {
|
||||
assert.equal(error.message, "ai.opencode.v2.unavailable");
|
||||
assert.equal(String(error).includes("terminated"), false);
|
||||
return true;
|
||||
});
|
||||
});
|
||||
|
||||
test("listCatalog omits the location query when no directory is set", async () => {
|
||||
const urls = [];
|
||||
const client = createOpenCodeV2Client({
|
||||
baseUrl: "http://127.0.0.1:4096/",
|
||||
username: "opencode",
|
||||
password: "pw",
|
||||
fetchImpl: async (url) => {
|
||||
urls.push(String(url));
|
||||
if (String(url).includes("/api/model/default")) return jsonResponse({ data: null });
|
||||
if (String(url).includes("/api/provider")) return jsonResponse({ data: [] });
|
||||
return jsonResponse({ data: [] });
|
||||
},
|
||||
});
|
||||
await client.listCatalog();
|
||||
assert.equal(urls.some((url) => url.includes("location")), false);
|
||||
});
|
||||
|
||||
test("translateOpenCodeV2Event streams text, tools, and a safe session failure", () => {
|
||||
const events = [];
|
||||
const emitter = {
|
||||
text: (t) => events.push(["text", t]),
|
||||
reasoning: (t) => events.push(["reasoning", t]),
|
||||
reasoningEnd: () => events.push(["reasoningEnd"]),
|
||||
toolCall: (name, input, id) => events.push(["tool", name, input, id]),
|
||||
toolResult: (id, output, name) => events.push(["result", id, output, name]),
|
||||
emitError: (message) => events.push(["error", message]),
|
||||
};
|
||||
const state = { sessionID: "ses_1" };
|
||||
assert.equal(translateOpenCodeV2Event({
|
||||
type: "session.text.delta",
|
||||
data: { sessionID: "ses_1", delta: "Hello" },
|
||||
}, emitter, state).content, true);
|
||||
translateOpenCodeV2Event({
|
||||
type: "session.text.delta",
|
||||
data: { sessionID: "other", delta: "nope" },
|
||||
}, emitter, state);
|
||||
translateOpenCodeV2Event({
|
||||
type: "session.tool.input.started",
|
||||
data: { sessionID: "ses_1", id: "call_1", name: "bash" },
|
||||
}, emitter, state);
|
||||
translateOpenCodeV2Event({
|
||||
type: "session.tool.called",
|
||||
data: { sessionID: "ses_1", id: "call_1", input: { cmd: "ls" } },
|
||||
}, emitter, state);
|
||||
translateOpenCodeV2Event({
|
||||
type: "session.tool.failed",
|
||||
data: { sessionID: "ses_1", id: "call_1", error: { message: "command not found" } },
|
||||
}, emitter, state);
|
||||
const failed = translateOpenCodeV2Event({
|
||||
type: "session.execution.failed",
|
||||
data: { sessionID: "ses_1", error: { message: "server password hunter2\nstack" } },
|
||||
}, emitter, state);
|
||||
assert.equal(failed.error, true);
|
||||
assert.deepEqual(events, [
|
||||
["text", "Hello"],
|
||||
["tool", "bash", { cmd: "ls" }, "call_1"],
|
||||
["result", "call_1", "command not found", "bash"],
|
||||
["error", "ai.opencode.v2.sessionFailed"],
|
||||
]);
|
||||
assert.equal(JSON.stringify(events).includes("hunter2"), false);
|
||||
});
|
||||
|
||||
test("runOpenCodeV2Turn authenticates, keeps the user prompt unchanged, and streams the session", async () => {
|
||||
const requests = [];
|
||||
const encoder = new TextEncoder();
|
||||
const sse = new ReadableStream({
|
||||
start(controller) {
|
||||
const send = (event) => controller.enqueue(encoder.encode(`data: ${JSON.stringify(event)}\n\n`));
|
||||
send({ type: "server.connected", data: {} });
|
||||
send({ type: "session.reasoning.delta", data: { sessionID: "ses_1", delta: "Think" } });
|
||||
send({ type: "session.reasoning.ended", data: { sessionID: "ses_1" } });
|
||||
send({ type: "session.text.delta", data: { sessionID: "ses_1", delta: "Hello" } });
|
||||
send({ type: "session.execution.succeeded", data: { sessionID: "ses_1" } });
|
||||
controller.close();
|
||||
},
|
||||
});
|
||||
const fetchImpl = async (url, init) => {
|
||||
requests.push({ url: String(url), method: init.method, headers: init.headers, body: init.body });
|
||||
const path = new URL(url).pathname;
|
||||
if (path === "/api/session" && init.method === "POST") return jsonResponse({ data: { id: "ses_1" } });
|
||||
if (path.includes("/instructions/")) return new Response(null, { status: 204 });
|
||||
if (path === "/api/event") {
|
||||
return new Response(sse, { status: 200, headers: { "content-type": "text/event-stream" } });
|
||||
}
|
||||
if (path.endsWith("/prompt")) return jsonResponse({ data: { id: "msg_1" } });
|
||||
return jsonResponse({ data: {} });
|
||||
};
|
||||
const events = [];
|
||||
let closed = false;
|
||||
const result = await runOpenCodeV2Turn({
|
||||
prompt: "hello",
|
||||
systemPrompt: "netcatty context",
|
||||
cwd: "/workspace",
|
||||
model: "openai/gpt-5",
|
||||
emitter: {
|
||||
sessionId: (id) => events.push(["session", id]),
|
||||
text: (t) => events.push(["text", t]),
|
||||
reasoning: (t) => events.push(["reasoning", t]),
|
||||
reasoningEnd: () => events.push(["reasoningEnd"]),
|
||||
emitDone: () => events.push(["done"]),
|
||||
emitError: (message) => events.push(["error", message]),
|
||||
warning: (id, message) => events.push(["warning", id, message]),
|
||||
},
|
||||
openCodeV2Factory: async () => ({
|
||||
client: createOpenCodeV2Client({
|
||||
baseUrl: "http://127.0.0.1:4096",
|
||||
username: "opencode",
|
||||
password: "s3cret",
|
||||
fetchImpl,
|
||||
}),
|
||||
server: { close: () => { closed = true; } },
|
||||
}),
|
||||
});
|
||||
|
||||
assert.equal(result.sessionId, "ses_1");
|
||||
assert.equal(closed, true);
|
||||
assert.deepEqual(events, [
|
||||
["session", "ses_1"],
|
||||
["reasoning", "Think"],
|
||||
["reasoningEnd"],
|
||||
["text", "Hello"],
|
||||
["done"],
|
||||
]);
|
||||
const created = requests.find((request) => new URL(request.url).pathname === "/api/session");
|
||||
assert.deepEqual(JSON.parse(created.body).model, { id: "gpt-5", providerID: "openai" });
|
||||
const prompt = requests.find((request) => request.url.endsWith("/prompt"));
|
||||
assert.equal(JSON.parse(prompt.body).text, "hello");
|
||||
assert.equal(JSON.parse(prompt.body).text.includes("netcatty context"), false);
|
||||
const instruction = requests.find((request) => request.url.includes("/instructions/"));
|
||||
assert.equal(JSON.parse(instruction.body).value, "netcatty context");
|
||||
for (const request of requests) {
|
||||
assert.equal(request.headers.Authorization, buildOpenCodeBasicAuthorization("opencode", "s3cret"));
|
||||
}
|
||||
});
|
||||
|
||||
test("runOpenCodeV2Turn continues when the instruction entry is rejected", async () => {
|
||||
const requests = [];
|
||||
const encoder = new TextEncoder();
|
||||
const sse = new ReadableStream({
|
||||
start(controller) {
|
||||
controller.enqueue(encoder.encode(`data: ${JSON.stringify({
|
||||
type: "session.text.delta",
|
||||
data: { sessionID: "ses_1", delta: "Hello" },
|
||||
})}\n\n`));
|
||||
controller.enqueue(encoder.encode(`data: ${JSON.stringify({
|
||||
type: "session.execution.succeeded",
|
||||
data: { sessionID: "ses_1" },
|
||||
})}\n\n`));
|
||||
controller.close();
|
||||
},
|
||||
});
|
||||
const fetchImpl = async (url, init) => {
|
||||
requests.push({ url: String(url), method: init.method, body: init.body });
|
||||
const path = new URL(url).pathname;
|
||||
if (path === "/api/session" && init.method === "POST") return jsonResponse({ data: { id: "ses_1" } });
|
||||
if (path.includes("/instructions/")) {
|
||||
return new Response(JSON.stringify({ message: "value must be an object" }), {
|
||||
status: 400,
|
||||
headers: { "content-type": "application/json" },
|
||||
});
|
||||
}
|
||||
if (path === "/api/event") {
|
||||
return new Response(sse, { status: 200, headers: { "content-type": "text/event-stream" } });
|
||||
}
|
||||
if (path.endsWith("/prompt")) return jsonResponse({ data: { id: "msg_1" } });
|
||||
return jsonResponse({ data: {} });
|
||||
};
|
||||
const events = [];
|
||||
const result = await runOpenCodeV2Turn({
|
||||
prompt: "hello",
|
||||
systemPrompt: "netcatty context",
|
||||
emitter: {
|
||||
sessionId: (id) => events.push(["session", id]),
|
||||
text: (text) => events.push(["text", text]),
|
||||
emitDone: () => events.push(["done"]),
|
||||
emitError: (message) => events.push(["error", message]),
|
||||
warning: (id, message) => events.push(["warning", id, message]),
|
||||
},
|
||||
openCodeV2Factory: async () => ({
|
||||
client: createOpenCodeV2Client({
|
||||
baseUrl: "http://127.0.0.1:4096",
|
||||
username: "opencode",
|
||||
password: "s3cret",
|
||||
fetchImpl,
|
||||
}),
|
||||
server: { close() {} },
|
||||
}),
|
||||
});
|
||||
assert.equal(result.sessionId, "ses_1");
|
||||
assert.deepEqual(events, [
|
||||
["session", "ses_1"],
|
||||
["warning", "opencode-v2-context", "ai.opencode.v2.contextSkipped"],
|
||||
["text", "Hello"],
|
||||
["done"],
|
||||
]);
|
||||
const prompt = requests.find((request) => request.url.endsWith("/prompt"));
|
||||
assert.equal(JSON.parse(prompt.body).text, "hello");
|
||||
const instruction = requests.find((request) => request.url.includes("/instructions/"));
|
||||
assert.equal(JSON.parse(instruction.body).value, "netcatty context");
|
||||
});
|
||||
|
||||
test("runOpenCodeV2Turn posts the prompt only after the event stream is open", async () => {
|
||||
let releaseEvent;
|
||||
const eventGate = new Promise((resolve) => { releaseEvent = resolve; });
|
||||
const order = [];
|
||||
const encoder = new TextEncoder();
|
||||
const fetchImpl = async (url) => {
|
||||
const path = new URL(url).pathname;
|
||||
if (path === "/api/session") return jsonResponse({ data: { id: "ses_1" } });
|
||||
if (path === "/api/event") {
|
||||
order.push("event-start");
|
||||
await eventGate;
|
||||
order.push("event-open");
|
||||
const sse = new ReadableStream({
|
||||
start(controller) {
|
||||
controller.enqueue(encoder.encode(`data: ${JSON.stringify({
|
||||
type: "session.text.delta",
|
||||
data: { sessionID: "ses_1", delta: "Hi" },
|
||||
})}\n\n`));
|
||||
controller.enqueue(encoder.encode(`data: ${JSON.stringify({
|
||||
type: "session.execution.succeeded",
|
||||
data: { sessionID: "ses_1" },
|
||||
})}\n\n`));
|
||||
controller.close();
|
||||
},
|
||||
});
|
||||
return new Response(sse, { status: 200, headers: { "content-type": "text/event-stream" } });
|
||||
}
|
||||
if (path.endsWith("/prompt")) {
|
||||
order.push("prompt");
|
||||
return jsonResponse({ data: { id: "msg_1" } });
|
||||
}
|
||||
return new Response(null, { status: 204 });
|
||||
};
|
||||
const events = [];
|
||||
const running = runOpenCodeV2Turn({
|
||||
prompt: "hello",
|
||||
emitter: {
|
||||
sessionId: () => {},
|
||||
text: (text) => events.push(text),
|
||||
emitDone: () => events.push("done"),
|
||||
emitError: (message) => events.push(["error", message]),
|
||||
},
|
||||
openCodeV2Factory: async () => ({
|
||||
client: createOpenCodeV2Client({
|
||||
baseUrl: "http://127.0.0.1:4096",
|
||||
username: "opencode",
|
||||
password: "s3cret",
|
||||
fetchImpl,
|
||||
}),
|
||||
server: { close() {} },
|
||||
}),
|
||||
});
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
assert.deepEqual(order, ["event-start"]);
|
||||
releaseEvent();
|
||||
await running;
|
||||
assert.deepEqual(order, ["event-start", "event-open", "prompt"]);
|
||||
assert.deepEqual(events, ["Hi", "done"]);
|
||||
});
|
||||
|
||||
test("runOpenCodeV2Turn reports a rejected prompt without waiting on the event stream", { timeout: 2000 }, async () => {
|
||||
let releaseEvent;
|
||||
const eventOpen = new Promise((resolve) => { releaseEvent = resolve; });
|
||||
let streamCancelled = false;
|
||||
const sse = new ReadableStream({
|
||||
start() {},
|
||||
cancel() { streamCancelled = true; },
|
||||
});
|
||||
const fetchImpl = async (url, init) => {
|
||||
const path = new URL(url).pathname;
|
||||
if (path === "/api/session" && init.method === "POST") return jsonResponse({ data: { id: "ses_1" } });
|
||||
if (path === "/api/event") {
|
||||
releaseEvent();
|
||||
return new Response(sse, { status: 200, headers: { "content-type": "text/event-stream" } });
|
||||
}
|
||||
if (path.endsWith("/prompt")) {
|
||||
await eventOpen;
|
||||
return new Response(JSON.stringify({ message: "model not found" }), {
|
||||
status: 400,
|
||||
headers: { "content-type": "application/json" },
|
||||
});
|
||||
}
|
||||
return new Response(null, { status: 204 });
|
||||
};
|
||||
const events = [];
|
||||
let closed = false;
|
||||
const result = await runOpenCodeV2Turn({
|
||||
prompt: "hello",
|
||||
emitter: {
|
||||
sessionId: (id) => events.push(["session", id]),
|
||||
text: () => {},
|
||||
emitDone: () => events.push(["done"]),
|
||||
emitError: (message) => events.push(["error", message]),
|
||||
},
|
||||
openCodeV2Factory: async () => ({
|
||||
client: createOpenCodeV2Client({
|
||||
baseUrl: "http://127.0.0.1:4096",
|
||||
username: "opencode",
|
||||
password: "s3cret",
|
||||
fetchImpl,
|
||||
}),
|
||||
server: { close: () => { closed = true; } },
|
||||
}),
|
||||
});
|
||||
assert.equal(result.sessionId, "ses_1");
|
||||
assert.equal(closed, true);
|
||||
assert.equal(streamCancelled, true);
|
||||
assert.deepEqual(events, [
|
||||
["session", "ses_1"],
|
||||
["error", "model not found"],
|
||||
]);
|
||||
});
|
||||
|
||||
test("runOpenCodeV2Turn maps a mid-stream server disconnect to a localized failure", async () => {
|
||||
const encoder = new TextEncoder();
|
||||
const disconnect = new TypeError("terminated");
|
||||
disconnect.cause = Object.assign(new Error("other side closed"), { code: "UND_ERR_SOCKET" });
|
||||
let sent = false;
|
||||
const sse = new ReadableStream({
|
||||
pull(controller) {
|
||||
if (!sent) {
|
||||
sent = true;
|
||||
controller.enqueue(encoder.encode(`data: ${JSON.stringify({
|
||||
type: "session.text.delta",
|
||||
data: { sessionID: "ses_1", delta: "Hel" },
|
||||
})}\n\n`));
|
||||
return;
|
||||
}
|
||||
controller.error(disconnect);
|
||||
},
|
||||
});
|
||||
const fetchImpl = async (url, init) => {
|
||||
const path = new URL(url).pathname;
|
||||
if (path === "/api/session" && init.method === "POST") return jsonResponse({ data: { id: "ses_1" } });
|
||||
if (path === "/api/event") {
|
||||
return new Response(sse, { status: 200, headers: { "content-type": "text/event-stream" } });
|
||||
}
|
||||
if (path.endsWith("/prompt")) return jsonResponse({ data: { id: "msg_1" } });
|
||||
return new Response(null, { status: 204 });
|
||||
};
|
||||
const events = [];
|
||||
const result = await runOpenCodeV2Turn({
|
||||
prompt: "hello",
|
||||
emitter: {
|
||||
sessionId: (id) => events.push(["session", id]),
|
||||
text: (text) => events.push(["text", text]),
|
||||
emitDone: () => events.push(["done"]),
|
||||
emitError: (message) => events.push(["error", message]),
|
||||
},
|
||||
openCodeV2Factory: async () => ({
|
||||
client: createOpenCodeV2Client({
|
||||
baseUrl: "http://127.0.0.1:4096",
|
||||
username: "opencode",
|
||||
password: "s3cret",
|
||||
fetchImpl,
|
||||
}),
|
||||
server: { close() {} },
|
||||
}),
|
||||
});
|
||||
assert.equal(result.sessionId, "ses_1");
|
||||
assert.deepEqual(events, [
|
||||
["session", "ses_1"],
|
||||
["text", "Hel"],
|
||||
["error", "ai.opencode.v2.sessionFailed"],
|
||||
]);
|
||||
assert.equal(JSON.stringify(events).includes("terminated"), false);
|
||||
assert.equal(JSON.stringify(events).includes("other side closed"), false);
|
||||
});
|
||||
|
||||
test("runOpenCodeV2Turn maps an event stream that ends before the turn finishes", async () => {
|
||||
const encoder = new TextEncoder();
|
||||
const sse = new ReadableStream({
|
||||
start(controller) {
|
||||
controller.enqueue(encoder.encode(`data: ${JSON.stringify({
|
||||
type: "session.text.delta",
|
||||
data: { sessionID: "ses_1", delta: "Hel" },
|
||||
})}\n\n`));
|
||||
controller.close();
|
||||
},
|
||||
});
|
||||
const fetchImpl = async (url, init) => {
|
||||
const path = new URL(url).pathname;
|
||||
if (path === "/api/session" && init.method === "POST") return jsonResponse({ data: { id: "ses_1" } });
|
||||
if (path === "/api/event") {
|
||||
return new Response(sse, { status: 200, headers: { "content-type": "text/event-stream" } });
|
||||
}
|
||||
if (path.endsWith("/prompt")) return jsonResponse({ data: { id: "msg_1" } });
|
||||
return new Response(null, { status: 204 });
|
||||
};
|
||||
const events = [];
|
||||
await runOpenCodeV2Turn({
|
||||
prompt: "hello",
|
||||
emitter: {
|
||||
sessionId: (id) => events.push(["session", id]),
|
||||
text: (text) => events.push(["text", text]),
|
||||
emitDone: () => events.push(["done"]),
|
||||
emitError: (message) => events.push(["error", message]),
|
||||
},
|
||||
openCodeV2Factory: async () => ({
|
||||
client: createOpenCodeV2Client({
|
||||
baseUrl: "http://127.0.0.1:4096",
|
||||
username: "opencode",
|
||||
password: "s3cret",
|
||||
fetchImpl,
|
||||
}),
|
||||
server: { close() {} },
|
||||
}),
|
||||
});
|
||||
assert.deepEqual(events, [
|
||||
["session", "ses_1"],
|
||||
["text", "Hel"],
|
||||
["error", "ai.opencode.v2.sessionFailed"],
|
||||
]);
|
||||
});
|
||||
|
||||
test("listOpenCodeModels stays on the 1.x factory for OpenCode 1.x", async () => {
|
||||
resetOpenCodeListServerPool();
|
||||
let v1 = false;
|
||||
const models = await listOpenCodeModels({
|
||||
versionProbe: async () => ({ major: 1, minor: 19, patch: 3 }),
|
||||
openCodeFactory: async () => {
|
||||
v1 = true;
|
||||
return {
|
||||
client: { config: { providers: async () => ({ providers: [], default: {} }) } },
|
||||
server: { close() {} },
|
||||
};
|
||||
},
|
||||
openCodeV2Factory: async () => {
|
||||
throw new Error("v2 catalog should not run");
|
||||
},
|
||||
});
|
||||
assert.equal(v1, true);
|
||||
assert.deepEqual(models.models, []);
|
||||
resetOpenCodeListServerPool();
|
||||
});
|
||||
|
||||
test("listOpenCodeV2Models refetches the catalog after model.updated", async () => {
|
||||
let updated = false;
|
||||
const result = await listOpenCodeV2Models({
|
||||
openCodeV2Factory: async () => ({
|
||||
client: {
|
||||
async listCatalog() {
|
||||
if (!updated) {
|
||||
return { currentModelId: null, models: [{ id: "opencode/big-pickle", name: "builtin" }] };
|
||||
}
|
||||
return {
|
||||
currentModelId: "custom/coder",
|
||||
models: [
|
||||
{ id: "opencode/big-pickle", name: "builtin" },
|
||||
{ id: "custom/coder", name: "Coder" },
|
||||
],
|
||||
};
|
||||
},
|
||||
async openEventStream() {
|
||||
return (async function* partialCatalog() {
|
||||
await new Promise((resolve) => setTimeout(resolve, 30));
|
||||
updated = true;
|
||||
yield { type: "model.updated", data: {} };
|
||||
await new Promise(() => {});
|
||||
})();
|
||||
},
|
||||
},
|
||||
server: { close() {} },
|
||||
}),
|
||||
});
|
||||
assert.deepEqual(result.models.map((model) => model.id), ["opencode/big-pickle", "custom/coder"]);
|
||||
assert.equal(result.currentModelId, "custom/coder");
|
||||
});
|
||||
|
||||
test("listOpenCodeV2Models refetches when model.updated arrives during the first catalog read", async () => {
|
||||
let releaseFirst;
|
||||
const firstCatalog = new Promise((resolve) => { releaseFirst = resolve; });
|
||||
let calls = 0;
|
||||
const result = await listOpenCodeV2Models({
|
||||
openCodeV2Factory: async () => ({
|
||||
client: {
|
||||
async listCatalog() {
|
||||
calls += 1;
|
||||
if (calls === 1) {
|
||||
await firstCatalog;
|
||||
return { currentModelId: null, models: [{ id: "opencode/big-pickle", name: "builtin" }] };
|
||||
}
|
||||
return {
|
||||
currentModelId: "custom/coder",
|
||||
models: [
|
||||
{ id: "opencode/big-pickle", name: "builtin" },
|
||||
{ id: "custom/coder", name: "Coder" },
|
||||
],
|
||||
};
|
||||
},
|
||||
async openEventStream() {
|
||||
return (async function* updateDuringFetch() {
|
||||
await new Promise((resolve) => setTimeout(resolve, 20));
|
||||
yield { type: "model.updated", data: {} };
|
||||
releaseFirst();
|
||||
await new Promise(() => {});
|
||||
})();
|
||||
},
|
||||
},
|
||||
server: { close() {} },
|
||||
}),
|
||||
});
|
||||
assert.ok(calls >= 2);
|
||||
assert.equal(result.currentModelId, "custom/coder");
|
||||
assert.deepEqual(result.models.map((model) => model.id), ["opencode/big-pickle", "custom/coder"]);
|
||||
});
|
||||
|
||||
test("listOpenCodeV2Models refetches when the settlement budget elapses during the first read", async () => {
|
||||
let releaseFirst;
|
||||
const firstCatalog = new Promise((resolve) => { releaseFirst = resolve; });
|
||||
let calls = 0;
|
||||
const result = await listOpenCodeV2Models({
|
||||
catalogSettle: { maxMs: 30, quietMs: 5000, pollMs: 10 },
|
||||
openCodeV2Factory: async () => ({
|
||||
client: {
|
||||
async listCatalog() {
|
||||
calls += 1;
|
||||
if (calls === 1) {
|
||||
await firstCatalog;
|
||||
return { currentModelId: null, models: [{ id: "opencode/big-pickle", name: "builtin" }] };
|
||||
}
|
||||
return {
|
||||
currentModelId: "custom/coder",
|
||||
models: [
|
||||
{ id: "opencode/big-pickle", name: "builtin" },
|
||||
{ id: "custom/coder", name: "Coder" },
|
||||
],
|
||||
};
|
||||
},
|
||||
async openEventStream() {
|
||||
return (async function* updateAfterDeadline() {
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
yield { type: "provider.updated", data: {} };
|
||||
releaseFirst();
|
||||
await new Promise(() => {});
|
||||
})();
|
||||
},
|
||||
},
|
||||
server: { close() {} },
|
||||
}),
|
||||
});
|
||||
assert.ok(calls >= 2);
|
||||
assert.equal(result.currentModelId, "custom/coder");
|
||||
});
|
||||
|
||||
test("listOpenCodeModels uses the 2.x catalog when the binary is 2.x", async () => {
|
||||
const models = await listOpenCodeModels({
|
||||
versionProbe: async () => parseOpenCodeVersionOutput("v2.1.0"),
|
||||
openCodeV2Factory: async () => ({
|
||||
client: {
|
||||
listCatalog: async () => ({
|
||||
currentModelId: "openai/gpt-5",
|
||||
models: [{ id: "openai/gpt-5", name: "OpenAI GPT-5" }],
|
||||
}),
|
||||
},
|
||||
server: { close() {} },
|
||||
}),
|
||||
});
|
||||
assert.equal(models.currentModelId, "openai/gpt-5");
|
||||
});
|
||||
|
||||
test("runOpenCodeTurn keeps the injected 1.x factory when a factory is supplied", async () => {
|
||||
const events = [];
|
||||
await runOpenCodeTurn({
|
||||
prompt: "hi",
|
||||
versionProbe: async () => ({ major: 2, minor: 1, patch: 0 }),
|
||||
emitter: {
|
||||
sessionId: () => {},
|
||||
text: (t) => events.push(t),
|
||||
emitDone: () => events.push("done"),
|
||||
emitError: (message) => events.push(message),
|
||||
},
|
||||
openCodeFactory: async () => ({
|
||||
client: {
|
||||
global: { event: async () => ({ stream: (async function* empty() {})() }) },
|
||||
session: {
|
||||
create: async () => ({ data: { id: "sess-1" } }),
|
||||
promptAsync: async () => ({ data: true }),
|
||||
},
|
||||
},
|
||||
server: { close() {} },
|
||||
}),
|
||||
});
|
||||
assert.ok(events.includes("ai.opencode.v2.empty") === false);
|
||||
assert.ok(events.includes("done") || events.some((event) => String(event).includes("empty")));
|
||||
});
|
||||
|
||||
test("listOpenCodeV2Models returns an empty catalog when the caller already aborted", async () => {
|
||||
const controller = new AbortController();
|
||||
controller.abort();
|
||||
const models = await listOpenCodeV2Models({
|
||||
signal: controller.signal,
|
||||
openCodeV2Factory: async () => { throw new Error("should not connect"); },
|
||||
});
|
||||
assert.deepEqual(models, { currentModelId: null, models: [] });
|
||||
});
|
||||
|
||||
function jsonResponse(body, status = 200) {
|
||||
return new Response(JSON.stringify(body), {
|
||||
status,
|
||||
headers: { "content-type": "application/json" },
|
||||
});
|
||||
}
|
||||
@@ -1142,6 +1142,7 @@ async function startPortForward(event, payload) {
|
||||
authPolicyVersion,
|
||||
requiresMfa,
|
||||
password,
|
||||
totp,
|
||||
privateKey,
|
||||
publicKey,
|
||||
certificate,
|
||||
@@ -1660,6 +1661,7 @@ async function startPortForward(event, payload) {
|
||||
hostId,
|
||||
hostname,
|
||||
password,
|
||||
totp,
|
||||
logPrefix: "[PortForward]",
|
||||
scope: "external",
|
||||
getAuthBanner: () => authBanner,
|
||||
|
||||
@@ -444,6 +444,7 @@ function createOpenConnectionApi(ctx) {
|
||||
hostId: jump.hostId,
|
||||
hostname: hopLabel,
|
||||
password: jump.password,
|
||||
totp: jump.totp,
|
||||
logPrefix: `[SFTP Chain] Hop ${i + 1}/${jumpHosts.length}`,
|
||||
scope: "external",
|
||||
getAuthBanner: () => authBanner,
|
||||
@@ -1137,6 +1138,7 @@ function createOpenConnectionApi(ctx) {
|
||||
hostId: options.hostId,
|
||||
hostname: options.hostname,
|
||||
password: options.password,
|
||||
totp: options.totp,
|
||||
logPrefix: "[SFTP]",
|
||||
scope: "external",
|
||||
getAuthBanner: () => authBanner,
|
||||
|
||||
@@ -12,6 +12,7 @@ const { exec } = childProcess;
|
||||
const { utils: sshUtils } = require("ssh2");
|
||||
const systemSshAgent = require("./systemSshAgent.cjs");
|
||||
const keyboardInteractiveHandler = require("./keyboardInteractiveHandler.cjs");
|
||||
const { planTotpAutoSubmit } = require("../../domain/totp.shared.cjs");
|
||||
const passphraseHandler = require("./passphraseHandler.cjs");
|
||||
const {
|
||||
normalizePrivateKeyForSsh2,
|
||||
@@ -2382,12 +2383,18 @@ function createKeyboardInteractiveHandler(options) {
|
||||
onPromptShown,
|
||||
onUserResponded,
|
||||
getAuthBanner,
|
||||
totp,
|
||||
now = () => Date.now(),
|
||||
schedule = (fn, ms) => setTimeout(fn, ms),
|
||||
} = options;
|
||||
// ssh2 may re-invoke the keyboard-interactive event on auth failure with a
|
||||
// fresh challenge. If our first auto-fill attempt was wrong, falling back
|
||||
// to the modal on the retry lets the user correct it — and prevents a
|
||||
// tight loop where we keep submitting the same wrong password.
|
||||
let autoFilledOnce = false;
|
||||
// TOTP is independent of the password fast-path: a PAM password prompt and
|
||||
// a later verification-code prompt share this handler. Submit at most once.
|
||||
let totpAutoSubmitted = false;
|
||||
|
||||
return (name, instructions, instructionsLang, prompts, finish) => {
|
||||
console.log(`${logPrefix} ${hostname} keyboard-interactive auth requested`, {
|
||||
@@ -2446,6 +2453,41 @@ function createKeyboardInteractiveHandler(options) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!totpAutoSubmitted && totp?.uri) {
|
||||
let totpPlan = { action: "skip" };
|
||||
try {
|
||||
totpPlan = planTotpAutoSubmit({
|
||||
uri: totp.uri,
|
||||
prompts,
|
||||
name,
|
||||
instructions,
|
||||
promptPattern: totp.promptPattern,
|
||||
autoSubmit: totp.autoSubmit,
|
||||
timestamp: now(),
|
||||
});
|
||||
} catch {
|
||||
console.warn(`${logPrefix} TOTP auto-submit check failed`);
|
||||
totpPlan = { action: "fail" };
|
||||
}
|
||||
if (totpPlan.action === "fail") {
|
||||
totpAutoSubmitted = true;
|
||||
} else if (totpPlan.action === "submit") {
|
||||
totpAutoSubmitted = true;
|
||||
const submitCode = () => {
|
||||
console.log(`${logPrefix} Auto-submitting TOTP for a matching keyboard-interactive prompt`);
|
||||
try { onAutoFill?.("totp"); } catch (err) { console.warn(`${logPrefix} onAutoFill callback threw`, err); }
|
||||
try { finish([totpPlan.code]); } catch { console.warn(`${logPrefix} TOTP finish threw`); }
|
||||
};
|
||||
if (totpPlan.delayMs > 0) {
|
||||
const timer = schedule(submitCode, totpPlan.delayMs);
|
||||
timer?.unref?.();
|
||||
} else {
|
||||
submitCode();
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Forward prompts to user via IPC
|
||||
const requestId = keyboardInteractiveHandler.generateRequestId('ssh');
|
||||
keyboardInteractiveHandler.storeRequest(requestId, (userResponses) => {
|
||||
@@ -2493,6 +2535,9 @@ function createKeyboardInteractiveHandler(options) {
|
||||
console.log(`${logPrefix} Showing modal for ${promptsData.length} prompts`);
|
||||
try { onPromptShown?.(); } catch (err) { console.warn(`${logPrefix} onPromptShown callback threw`, err); }
|
||||
|
||||
const totpUri = typeof totp?.uri === "string" && totp.uri && !totp.uri.startsWith("enc:v1:")
|
||||
? totp.uri
|
||||
: "";
|
||||
safeSend(sender, "netcatty:keyboard-interactive", {
|
||||
requestId,
|
||||
sessionId,
|
||||
@@ -2504,6 +2549,7 @@ function createKeyboardInteractiveHandler(options) {
|
||||
savedPassword: savedPasswordForModal,
|
||||
allowSavePassword,
|
||||
scope,
|
||||
...(totpUri ? { totpUri } : {}),
|
||||
...(Number.isFinite(bootEpoch) ? { bootEpoch: Number(bootEpoch) } : {}),
|
||||
});
|
||||
};
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
const test = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
|
||||
const { createKeyboardInteractiveHandler } = require("./sshAuthHelper.cjs");
|
||||
const keyboardInteractiveHandler = require("./keyboardInteractiveHandler.cjs");
|
||||
const { encodeBase32, generateTotp } = require("../../domain/totp.shared.cjs");
|
||||
|
||||
const SECRET = Buffer.from("12345678901234567890", "ascii");
|
||||
const TOTP = {
|
||||
uri: `otpauth://totp/JumpServer:root?secret=${encodeBase32(SECRET)}&digits=8&period=30`,
|
||||
autoSubmit: true,
|
||||
};
|
||||
|
||||
const createSender = () => {
|
||||
const sent = [];
|
||||
return {
|
||||
sent,
|
||||
sender: {
|
||||
id: 7,
|
||||
isDestroyed: () => false,
|
||||
send: (channel, payload) => sent.push({ channel, payload }),
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
const drainPendingRequests = (sent, senderId = 7) => {
|
||||
for (const event of sent) {
|
||||
if (event.channel !== "netcatty:keyboard-interactive") continue;
|
||||
const requestId = event.payload?.requestId;
|
||||
if (requestId) {
|
||||
keyboardInteractiveHandler.handleResponse({ sender: { id: senderId } }, { requestId, cancelled: true });
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const invoke = (handler, prompt, instructions = "") => {
|
||||
let finished = null;
|
||||
handler("root", instructions, "", [{ prompt, echo: true }], (responses) => {
|
||||
finished = responses;
|
||||
});
|
||||
return finished;
|
||||
};
|
||||
|
||||
test("TOTP auto-submit runs once per connection attempt, then falls back to the dialog", () => {
|
||||
const { sender, sent } = createSender();
|
||||
const handler = createKeyboardInteractiveHandler({
|
||||
sender,
|
||||
sessionId: "session-1",
|
||||
hostname: "jumpserver.example",
|
||||
totp: TOTP,
|
||||
now: () => 15_000,
|
||||
});
|
||||
const expected = generateTotp({
|
||||
secret: SECRET,
|
||||
algorithm: "SHA1",
|
||||
digits: 8,
|
||||
period: 30,
|
||||
timestamp: 15_000,
|
||||
});
|
||||
|
||||
assert.deepEqual(invoke(handler, "[OTP Code]: ", "Please Enter MFA Code."), [expected]);
|
||||
assert.equal(sent.length, 0);
|
||||
|
||||
const retry = invoke(handler, "[OTP Code]: ", "Please Enter MFA Code.");
|
||||
assert.equal(retry, null);
|
||||
assert.equal(sent.length, 1);
|
||||
assert.equal(sent[0].channel, "netcatty:keyboard-interactive");
|
||||
assert.equal(sent[0].payload.totpUri, TOTP.uri);
|
||||
drainPendingRequests(sent);
|
||||
});
|
||||
|
||||
test("keyboard-interactive dialog omits an encrypted TOTP placeholder", () => {
|
||||
const { sender, sent } = createSender();
|
||||
const handler = createKeyboardInteractiveHandler({
|
||||
sender,
|
||||
sessionId: "session-enc",
|
||||
hostname: "jumpserver.example",
|
||||
totp: { uri: "enc:v1:placeholder" },
|
||||
now: () => 15_000,
|
||||
});
|
||||
assert.equal(invoke(handler, "[OTP Code]: "), null);
|
||||
assert.equal(sent.length, 1);
|
||||
assert.equal(sent[0].payload.totpUri, undefined);
|
||||
drainPendingRequests(sent);
|
||||
});
|
||||
|
||||
test("a non-matching prompt does not consume the one TOTP auto-submit", () => {
|
||||
const { sender, sent } = createSender();
|
||||
const handler = createKeyboardInteractiveHandler({
|
||||
sender,
|
||||
sessionId: "session-2",
|
||||
hostname: "jumpserver.example",
|
||||
totp: TOTP,
|
||||
now: () => 15_000,
|
||||
});
|
||||
|
||||
assert.equal(invoke(handler, "\n1. OTP\n2. SMS\nOption> ", "Please Select MFA Option"), null);
|
||||
assert.equal(sent.length, 1);
|
||||
assert.equal(sent[0].payload.totpUri, TOTP.uri);
|
||||
drainPendingRequests(sent);
|
||||
|
||||
const code = invoke(handler, "[OTP Code]: ", "Please Enter MFA Code.");
|
||||
assert.equal(code?.[0]?.length, 8);
|
||||
assert.equal(sent.length, 1);
|
||||
});
|
||||
|
||||
test("SMS, email, and confirmation prompts are never auto-answered", () => {
|
||||
const { sender, sent } = createSender();
|
||||
const handler = createKeyboardInteractiveHandler({
|
||||
sender,
|
||||
sessionId: "session-3",
|
||||
hostname: "jumpserver.example",
|
||||
totp: TOTP,
|
||||
now: () => 15_000,
|
||||
});
|
||||
for (const prompt of ["[SMS Code]: ", "[EMAIL Code]: ", "Do you want to continue [Y/n]? : "]) {
|
||||
assert.equal(invoke(handler, prompt), null);
|
||||
}
|
||||
assert.equal(sent.length, 3);
|
||||
drainPendingRequests(sent);
|
||||
});
|
||||
|
||||
test("legacy JumpServer and PAM prompts auto-submit, and a later mismatch opens the dialog", () => {
|
||||
const { sender, sent } = createSender();
|
||||
const handler = createKeyboardInteractiveHandler({
|
||||
sender,
|
||||
sessionId: "session-4",
|
||||
hostname: "pam.example",
|
||||
totp: TOTP,
|
||||
now: () => 15_000,
|
||||
});
|
||||
assert.equal(invoke(handler, "[MFA auth]: ")?.[0]?.length, 8);
|
||||
const fresh = createKeyboardInteractiveHandler({
|
||||
sender,
|
||||
sessionId: "session-5",
|
||||
hostname: "pam.example",
|
||||
totp: { ...TOTP },
|
||||
now: () => 15_000,
|
||||
});
|
||||
assert.equal(invoke(fresh, "Verification code: ")?.[0]?.length, 8);
|
||||
assert.equal(invoke(fresh, "Password:"), null);
|
||||
assert.equal(sent.length, 1);
|
||||
drainPendingRequests(sent);
|
||||
});
|
||||
@@ -896,14 +896,16 @@ async function connectThroughChain(event, options, jumpHosts, targetHost, target
|
||||
hostId: jump.hostId,
|
||||
hostname: hopLabel,
|
||||
password: jump.password,
|
||||
totp: jump.totp,
|
||||
logPrefix: `[Chain] Hop ${i + 1}/${totalHops}`,
|
||||
scope: keyboardInteractiveScope,
|
||||
allowSavePassword: options._monitorMode !== true,
|
||||
bootEpoch: options.bootEpoch,
|
||||
getAuthBanner: () => authBanner,
|
||||
shouldSkipAutoFill: () => shouldSkipKiPasswordAutoFill(hopAuthPhase),
|
||||
onAutoFill: () => sendProgress(
|
||||
i + 1, totalHops + 1, hopLabel, 'auth-attempt', 'using saved password',
|
||||
onAutoFill: (kind) => sendProgress(
|
||||
i + 1, totalHops + 1, hopLabel, 'auth-attempt',
|
||||
kind === 'totp' ? 'using authenticator code' : 'using saved password',
|
||||
),
|
||||
onPromptShown: () => sendProgress(
|
||||
i + 1, totalHops + 1, hopLabel, 'auth-attempt', 'waiting for user input...',
|
||||
|
||||
@@ -201,6 +201,7 @@ function createExecCommandApi(ctx) {
|
||||
hostId: payload.hostId,
|
||||
hostname: payload.hostname,
|
||||
password: payload.password,
|
||||
totp: payload.totp,
|
||||
logPrefix: "[SSH Exec]",
|
||||
scope: "external",
|
||||
getAuthBanner: () => authBanner,
|
||||
|
||||
@@ -2817,6 +2817,7 @@ function createStartSessionApi(ctx) {
|
||||
hostId: options.hostId,
|
||||
hostname: options.hostname,
|
||||
password: options.password,
|
||||
totp: options.totp,
|
||||
logPrefix,
|
||||
// A test has no terminal session registered in the renderer's
|
||||
// session list, so the terminal-scoped keyboard-interactive queue
|
||||
@@ -2827,8 +2828,9 @@ function createStartSessionApi(ctx) {
|
||||
bootEpoch: options.bootEpoch,
|
||||
getAuthBanner: () => authBanner,
|
||||
shouldSkipAutoFill: () => shouldSkipKiPasswordAutoFill(authPhase),
|
||||
onAutoFill: () => sendProgress(
|
||||
totalHops, totalHops, options.hostname, 'auth-attempt', 'using saved password',
|
||||
onAutoFill: (kind) => sendProgress(
|
||||
totalHops, totalHops, options.hostname, 'auth-attempt',
|
||||
kind === 'totp' ? 'using authenticator code' : 'using saved password',
|
||||
),
|
||||
onPromptShown: () => sendProgress(
|
||||
totalHops, totalHops, options.hostname, 'auth-attempt', 'waiting for user input...',
|
||||
@@ -2881,10 +2883,13 @@ function createStartSessionApi(ctx) {
|
||||
// Password credentials still allow keyboard-interactive after
|
||||
// "password" so PAM/2FA hosts (which reject the raw "password"
|
||||
// method) are covered without re-prompting the user. Hosts marked
|
||||
// `requiresMfa` always get keyboard-interactive, including key /
|
||||
// certificate-only credentials where the server accepts the key as
|
||||
// the first factor and then asks for an OTP (matching the terminal
|
||||
// auth handler, which always appends keyboard-interactive).
|
||||
// `requiresMfa` and a configured TOTP secret always get
|
||||
// keyboard-interactive, including key / certificate-only credentials
|
||||
// where the server accepts the key as the first factor and then
|
||||
// asks for an OTP (matching the terminal auth handler, which always
|
||||
// appends keyboard-interactive).
|
||||
const totpUri = typeof options.totp?.uri === "string" ? options.totp.uri : "";
|
||||
const hasTotp = totpUri.length > 0 && !totpUri.startsWith("enc:v1:");
|
||||
const order = [];
|
||||
if (connectOpts.privateKey) {
|
||||
order.push("publickey");
|
||||
@@ -2894,7 +2899,7 @@ function createStartSessionApi(ctx) {
|
||||
if (connectOpts.password && !options.requiresMfa) {
|
||||
order.push("password");
|
||||
}
|
||||
if (connectOpts.password || options.requiresMfa || order.length === 0) {
|
||||
if (connectOpts.password || options.requiresMfa || hasTotp || order.length === 0) {
|
||||
order.push("keyboard-interactive");
|
||||
}
|
||||
connectOpts.authHandler = order;
|
||||
|
||||
@@ -313,6 +313,26 @@ test("test mode keeps key-only non-MFA hosts on the single key credential", asyn
|
||||
assert.deepEqual(calls.connectOptions.authHandler, ["publickey"]);
|
||||
});
|
||||
|
||||
test("test mode includes keyboard-interactive for key-only hosts with TOTP", async () => {
|
||||
const { api, calls, sender } = keyOnlyHarness();
|
||||
|
||||
await api.startSSHSession({ sender }, keyOnlyOptions({
|
||||
totp: { uri: "otpauth://totp/Example:root?secret=GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ&issuer=Example" },
|
||||
}));
|
||||
|
||||
assert.deepEqual(calls.connectOptions.authHandler, ["publickey", "keyboard-interactive"]);
|
||||
});
|
||||
|
||||
test("test mode ignores an encrypted TOTP placeholder", async () => {
|
||||
const { api, calls, sender } = keyOnlyHarness();
|
||||
|
||||
await api.startSSHSession({ sender }, keyOnlyOptions({
|
||||
totp: { uri: "enc:v1:placeholder" },
|
||||
}));
|
||||
|
||||
assert.deepEqual(calls.connectOptions.authHandler, ["publickey"]);
|
||||
});
|
||||
|
||||
test("test mode skips the raw password method for password MFA hosts", async () => {
|
||||
const { api, calls, sender } = createHarness({
|
||||
onConnect: (conn) => {
|
||||
|
||||
@@ -0,0 +1,167 @@
|
||||
"use strict";
|
||||
|
||||
const jsQR = require("jsqr");
|
||||
const {
|
||||
previewTotpText,
|
||||
selectTotpFromQrPayloads,
|
||||
bgraToRgba,
|
||||
currentTotp,
|
||||
} = require("../../domain/totp.shared.cjs");
|
||||
|
||||
const MAX_INPUT_LENGTH = 4096;
|
||||
|
||||
function screenCaptureStatus(electron) {
|
||||
try {
|
||||
return electron?.systemPreferences?.getMediaAccessStatus?.("screen") || "unknown";
|
||||
} catch {
|
||||
return "unknown";
|
||||
}
|
||||
}
|
||||
|
||||
function isScreenPermissionError(error) {
|
||||
const message = String(error?.message || error || "");
|
||||
return /denied|permission|not authorized|not permitted/i.test(message);
|
||||
}
|
||||
|
||||
function thumbnailSizeForDisplay(display) {
|
||||
const scale = Number(display?.scaleFactor) || 1;
|
||||
const width = Math.max(1, Math.round((display?.size?.width || display?.bounds?.width || 1920) * scale));
|
||||
const height = Math.max(1, Math.round((display?.size?.height || display?.bounds?.height || 1080) * scale));
|
||||
return { width, height };
|
||||
}
|
||||
|
||||
function largestThumbnailSize(displays) {
|
||||
if (!displays.length) return { width: 1920, height: 1080 };
|
||||
let width = 1;
|
||||
let height = 1;
|
||||
for (const display of displays) {
|
||||
const size = thumbnailSizeForDisplay(display);
|
||||
width = Math.max(width, size.width);
|
||||
height = Math.max(height, size.height);
|
||||
}
|
||||
return { width, height };
|
||||
}
|
||||
|
||||
function pushThumbnail(images, image) {
|
||||
if (!image?.getSize || typeof image.toBitmap !== "function") return;
|
||||
const size = image.getSize();
|
||||
if (!size?.width || !size?.height) return;
|
||||
images.push({ width: size.width, height: size.height, bitmap: image.toBitmap() });
|
||||
}
|
||||
|
||||
function sourceDisplayId(source) {
|
||||
if (source?.display_id == null) return "";
|
||||
return String(source.display_id);
|
||||
}
|
||||
|
||||
async function captureScreenBitmaps(electron) {
|
||||
const desktopCapturer = electron?.desktopCapturer;
|
||||
const screen = electron?.screen;
|
||||
if (!desktopCapturer?.getSources || !screen?.getAllDisplays) {
|
||||
throw new Error("unavailable");
|
||||
}
|
||||
const displays = screen.getAllDisplays() || [];
|
||||
const sources = await desktopCapturer.getSources({
|
||||
types: ["screen"],
|
||||
thumbnailSize: largestThumbnailSize(displays),
|
||||
fetchWindowIcons: false,
|
||||
});
|
||||
const images = [];
|
||||
const used = new Set();
|
||||
const take = (source) => {
|
||||
if (!source || used.has(source)) return;
|
||||
used.add(source);
|
||||
pushThumbnail(images, source.thumbnail);
|
||||
};
|
||||
const matchedDisplayIds = new Set();
|
||||
for (const display of displays) {
|
||||
const displayId = String(display?.id);
|
||||
const match = sources.find((source) => sourceDisplayId(source) !== "" && sourceDisplayId(source) === displayId);
|
||||
if (!match) continue;
|
||||
matchedDisplayIds.add(displayId);
|
||||
take(match);
|
||||
}
|
||||
for (const source of sources) {
|
||||
const displayId = sourceDisplayId(source);
|
||||
if (displayId && matchedDisplayIds.has(displayId)) continue;
|
||||
take(source);
|
||||
}
|
||||
return images;
|
||||
}
|
||||
|
||||
function decodeBitmaps(images, decode) {
|
||||
const payloads = [];
|
||||
for (const image of images) {
|
||||
const rgba = bgraToRgba(image.bitmap, image.width, image.height);
|
||||
const result = decode(rgba, image.width, image.height);
|
||||
if (result?.data) payloads.push(result.data);
|
||||
}
|
||||
return payloads;
|
||||
}
|
||||
|
||||
function publicPreviewFromParsed(parsed, timestamp) {
|
||||
const current = currentTotp(parsed, timestamp);
|
||||
return {
|
||||
ok: true,
|
||||
uri: parsed.uri,
|
||||
code: current.code,
|
||||
secondsRemaining: current.secondsRemaining,
|
||||
period: parsed.period,
|
||||
algorithm: parsed.algorithm,
|
||||
digits: parsed.digits,
|
||||
issuer: parsed.issuer,
|
||||
label: parsed.label,
|
||||
};
|
||||
}
|
||||
|
||||
function previewTotpInput(payload, now = Date.now()) {
|
||||
const text = typeof payload?.text === "string" ? payload.text : payload?.uri;
|
||||
if (typeof text !== "string" || text.length > MAX_INPUT_LENGTH) {
|
||||
return { ok: false, error: "invalid" };
|
||||
}
|
||||
return previewTotpText(text, now);
|
||||
}
|
||||
|
||||
async function scanScreensForTotp(deps = {}) {
|
||||
const electron = deps.electron;
|
||||
const status = screenCaptureStatus(electron);
|
||||
if (status === "denied" || status === "restricted") {
|
||||
return { ok: false, error: "screen-permission-denied" };
|
||||
}
|
||||
let images;
|
||||
try {
|
||||
images = await (deps.capture || captureScreenBitmaps)(electron);
|
||||
} catch (error) {
|
||||
if (isScreenPermissionError(error)) return { ok: false, error: "screen-permission-denied" };
|
||||
return { ok: false, error: "unavailable" };
|
||||
}
|
||||
if (!images?.length) {
|
||||
const platform = deps.platform || process.platform;
|
||||
if (platform === "darwin" && status !== "granted") {
|
||||
return { ok: false, error: "screen-permission-denied" };
|
||||
}
|
||||
return { ok: false, error: "not-found" };
|
||||
}
|
||||
const decode = deps.decode || ((data, width, height) => jsQR(data, width, height));
|
||||
let payloads;
|
||||
try {
|
||||
payloads = decodeBitmaps(images, decode);
|
||||
} catch {
|
||||
return { ok: false, error: "not-found" };
|
||||
}
|
||||
const selected = selectTotpFromQrPayloads(payloads);
|
||||
if (!selected.ok) return { ok: false, error: selected.error };
|
||||
return publicPreviewFromParsed(selected.parsed, deps.now?.() ?? Date.now());
|
||||
}
|
||||
|
||||
function registerTotpHandlers(ipcMain, electronModule) {
|
||||
ipcMain.handle("netcatty:totp:preview", (_event, payload) => previewTotpInput(payload));
|
||||
ipcMain.handle("netcatty:totp:scan-screen", () => scanScreensForTotp({ electron: electronModule }));
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
registerTotpHandlers,
|
||||
scanScreensForTotp,
|
||||
previewTotpInput,
|
||||
captureScreenBitmaps,
|
||||
};
|
||||
@@ -0,0 +1,140 @@
|
||||
const test = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
|
||||
const { scanScreensForTotp, previewTotpInput, captureScreenBitmaps } = require("./totpBridge.cjs");
|
||||
const { encodeBase32 } = require("../../domain/totp.shared.cjs");
|
||||
|
||||
const SECRET = Buffer.from("12345678901234567890", "ascii");
|
||||
const TOTP_URI = `otpauth://totp/Example:alice?secret=${encodeBase32(SECRET)}&algorithm=SHA1&digits=8&period=30`;
|
||||
|
||||
test("previewTotpInput returns the current code without echoing a separate secret field", () => {
|
||||
const preview = previewTotpInput({ text: TOTP_URI }, 59_000);
|
||||
assert.equal(preview.ok, true);
|
||||
assert.equal(preview.code, "94287082");
|
||||
assert.equal(preview.algorithm, "SHA1");
|
||||
assert.equal(preview.digits, 8);
|
||||
assert.equal("secret" in preview, false);
|
||||
assert.equal(previewTotpInput({ text: "otpauth://hotp/A?secret=GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ&counter=1" }).error, "hotp");
|
||||
assert.equal(previewTotpInput({ text: "otpauth-migration://offline?data=abc" }).error, "migration");
|
||||
});
|
||||
|
||||
test("screen scan returns a permission error when macOS screen recording is denied", async () => {
|
||||
const result = await scanScreensForTotp({
|
||||
electron: { systemPreferences: { getMediaAccessStatus: () => "denied" } },
|
||||
capture: async () => {
|
||||
throw new Error("capture should not run");
|
||||
},
|
||||
});
|
||||
assert.deepEqual(result, { ok: false, error: "screen-permission-denied" });
|
||||
});
|
||||
|
||||
test("screen scan takes the first otpauth totp URI and ignores a leading HOTP code", async () => {
|
||||
const result = await scanScreensForTotp({
|
||||
electron: { systemPreferences: { getMediaAccessStatus: () => "granted" } },
|
||||
capture: async () => [{ width: 1, height: 1, bitmap: Buffer.from([0, 0, 0, 255]) }],
|
||||
decode: () => ({ data: TOTP_URI }),
|
||||
now: () => 59_000,
|
||||
});
|
||||
assert.equal(result.ok, true);
|
||||
assert.equal(result.code, "94287082");
|
||||
assert.match(result.uri, /^otpauth:\/\/totp\//);
|
||||
assert.equal("secret" in result, false);
|
||||
});
|
||||
|
||||
test("an empty macOS capture without screen-recording permission is a clear denial", async () => {
|
||||
const result = await scanScreensForTotp({
|
||||
platform: "darwin",
|
||||
electron: { systemPreferences: { getMediaAccessStatus: () => "not-determined" } },
|
||||
capture: async () => [],
|
||||
});
|
||||
assert.equal(result.error, "screen-permission-denied");
|
||||
});
|
||||
|
||||
function thumbnail(label) {
|
||||
return {
|
||||
getSize: () => ({ width: 2, height: 2 }),
|
||||
toBitmap: () => Buffer.from(label),
|
||||
};
|
||||
}
|
||||
|
||||
test("screen capture scans each unmatched source once", async () => {
|
||||
let calls = 0;
|
||||
const images = await captureScreenBitmaps({
|
||||
screen: {
|
||||
getAllDisplays: () => [
|
||||
{ id: 1, size: { width: 800, height: 600 }, scaleFactor: 1 },
|
||||
{ id: 2, size: { width: 1920, height: 1080 }, scaleFactor: 1 },
|
||||
],
|
||||
},
|
||||
desktopCapturer: {
|
||||
getSources: async (options) => {
|
||||
calls += 1;
|
||||
assert.deepEqual(options.thumbnailSize, { width: 1920, height: 1080 });
|
||||
return [
|
||||
{ id: "left", display_id: "", thumbnail: thumbnail("left") },
|
||||
{ id: "right", display_id: "", thumbnail: thumbnail("right") },
|
||||
];
|
||||
},
|
||||
},
|
||||
});
|
||||
assert.equal(calls, 1);
|
||||
assert.deepEqual(images.map((image) => image.bitmap.toString()), ["left", "right"]);
|
||||
});
|
||||
|
||||
test("screen capture uses the largest width and height across orientations", async () => {
|
||||
let thumbnailSize;
|
||||
await captureScreenBitmaps({
|
||||
screen: {
|
||||
getAllDisplays: () => [
|
||||
{ id: 1, size: { width: 3840, height: 2160 }, scaleFactor: 1 },
|
||||
{ id: 2, size: { width: 2160, height: 3840 }, scaleFactor: 1 },
|
||||
],
|
||||
},
|
||||
desktopCapturer: {
|
||||
getSources: async (options) => {
|
||||
thumbnailSize = options.thumbnailSize;
|
||||
return [
|
||||
{ id: "landscape", display_id: "1", thumbnail: thumbnail("landscape") },
|
||||
{ id: "portrait", display_id: "2", thumbnail: thumbnail("portrait") },
|
||||
];
|
||||
},
|
||||
},
|
||||
});
|
||||
assert.deepEqual(thumbnailSize, { width: 3840, height: 3840 });
|
||||
});
|
||||
|
||||
test("screen capture keeps a matched display and still scans an unmatched source", async () => {
|
||||
const images = await captureScreenBitmaps({
|
||||
screen: {
|
||||
getAllDisplays: () => [
|
||||
{ id: 10, size: { width: 100, height: 100 }, scaleFactor: 2 },
|
||||
{ id: 20, size: { width: 100, height: 100 }, scaleFactor: 1 },
|
||||
],
|
||||
},
|
||||
desktopCapturer: {
|
||||
getSources: async (options) => {
|
||||
assert.deepEqual(options.thumbnailSize, { width: 200, height: 200 });
|
||||
return [
|
||||
{ id: "known", display_id: "10", thumbnail: thumbnail("known") },
|
||||
{ id: "other", display_id: "", thumbnail: thumbnail("other") },
|
||||
];
|
||||
},
|
||||
},
|
||||
});
|
||||
assert.deepEqual(images.map((image) => image.bitmap.toString()), ["known", "other"]);
|
||||
});
|
||||
|
||||
test("screen scan reports HOTP and a friendly miss", async () => {
|
||||
const hotp = await scanScreensForTotp({
|
||||
electron: { systemPreferences: { getMediaAccessStatus: () => "granted" } },
|
||||
capture: async () => [{ width: 1, height: 1, bitmap: Buffer.from([0, 0, 0, 255]) }],
|
||||
decode: () => ({ data: "otpauth://hotp/A?secret=GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ&counter=1" }),
|
||||
});
|
||||
assert.equal(hotp.error, "hotp");
|
||||
const missed = await scanScreensForTotp({
|
||||
electron: { systemPreferences: { getMediaAccessStatus: () => "granted" } },
|
||||
capture: async () => [{ width: 1, height: 1, bitmap: Buffer.from([0, 0, 0, 255]) }],
|
||||
decode: () => null,
|
||||
});
|
||||
assert.equal(missed.error, "not-found");
|
||||
});
|
||||
@@ -525,6 +525,8 @@ function createBridgeRegistrar(context) {
|
||||
compressUploadBridge.registerHandlers(ipcMain, { terminalWorkerManager });
|
||||
globalShortcutBridge.registerHandlers(ipcMain);
|
||||
credentialBridge.registerHandlers(ipcMain, electronModule);
|
||||
const { registerTotpHandlers } = require("../bridges/totpBridge.cjs");
|
||||
registerTotpHandlers(ipcMain, electronModule);
|
||||
autoUpdateBridge.init(deps);
|
||||
autoUpdateBridge.registerHandlers(ipcMain);
|
||||
aiBridge.registerHandlers(ipcMain);
|
||||
|
||||
@@ -6,7 +6,7 @@ const path = require("node:path");
|
||||
const { PLUGIN_LOG_MAX_BYTES, PLUGIN_LOG_MAX_FILES } = require("./constants.cjs");
|
||||
const { assertPluginStorageSegment } = require("./paths.cjs");
|
||||
|
||||
const SENSITIVE_KEY = /(?:password|passwd|token|secret|private.?key|credential|authorization|cookie)/iu;
|
||||
const SENSITIVE_KEY = /(?:password|passwd|token|secret|private.?key|credential|authorization|cookie|totp|otpauth)/iu;
|
||||
|
||||
function redactLogValue(value, key = "", depth = 0) {
|
||||
if (SENSITIVE_KEY.test(key)) return "[REDACTED]";
|
||||
|
||||
@@ -1865,6 +1865,8 @@ function createPreloadApi(ctx) {
|
||||
credentialsAvailable: () => ipcRenderer.invoke("netcatty:credentials:available"),
|
||||
credentialsEncrypt: (plaintext) => ipcRenderer.invoke("netcatty:credentials:encrypt", plaintext),
|
||||
credentialsDecrypt: (value) => ipcRenderer.invoke("netcatty:credentials:decrypt", value),
|
||||
previewTotp: (payload) => ipcRenderer.invoke("netcatty:totp:preview", payload),
|
||||
scanTotpQrFromScreen: () => ipcRenderer.invoke("netcatty:totp:scan-screen"),
|
||||
|
||||
// Auto-update
|
||||
checkForUpdate: () => ipcRenderer.invoke("netcatty:update:check"),
|
||||
|
||||
Vendored
+9
@@ -38,6 +38,12 @@ declare global {
|
||||
isDefault?: boolean;
|
||||
}
|
||||
|
||||
interface NetcattyTotpConfig {
|
||||
uri: string;
|
||||
autoSubmit?: boolean;
|
||||
promptPattern?: string;
|
||||
}
|
||||
|
||||
// Jump host configuration for SSH tunneling
|
||||
interface NetcattyJumpHost {
|
||||
hostname: string;
|
||||
@@ -47,6 +53,7 @@ declare global {
|
||||
authMethod?: import("./domain/models").HostAuthMethod;
|
||||
requiresMfa?: boolean;
|
||||
password?: string;
|
||||
totp?: NetcattyTotpConfig;
|
||||
privateKey?: string;
|
||||
certificate?: string;
|
||||
passphrase?: string;
|
||||
@@ -103,6 +110,7 @@ declare global {
|
||||
requiresMfa?: boolean;
|
||||
port?: number;
|
||||
password?: string;
|
||||
totp?: NetcattyTotpConfig;
|
||||
privateKey?: string;
|
||||
// Optional OpenSSH user certificate
|
||||
certificate?: string;
|
||||
@@ -221,6 +229,7 @@ declare global {
|
||||
authMethod?: import("./domain/models").HostAuthMethod;
|
||||
requiresMfa?: boolean;
|
||||
password?: string;
|
||||
totp?: NetcattyTotpConfig;
|
||||
privateKey?: string;
|
||||
certificate?: string;
|
||||
keyId?: string;
|
||||
|
||||
@@ -4,7 +4,7 @@ import type { AISession, AgentActivity, ChatMessage, ExternalAgentConfig } from
|
||||
import { SessionStateStore } from '../sessionState';
|
||||
import { ToolOutputStore } from '../toolOutputStore';
|
||||
import { ToolResultDedup } from '../toolResultDedup';
|
||||
import { externalSdkTurnDriver } from './externalSdkTurnDriver';
|
||||
import { externalSdkTurnDriver, localizeSdkAgentMessage } from './externalSdkTurnDriver';
|
||||
import { resolveEstimatedUsageFallback, upsertAgentActivity } from './externalSdkEventState';
|
||||
import type { TurnUiCallbacks } from './types';
|
||||
|
||||
@@ -16,6 +16,16 @@ async function waitFor(predicate: () => boolean): Promise<void> {
|
||||
throw new Error('condition not reached');
|
||||
}
|
||||
|
||||
test('OpenCode 2 error keys are localized and other errors stay unchanged', () => {
|
||||
const translate = (key: string) => key === 'ai.opencode.v2.startupTimeout' ? '未能及时就绪' : key;
|
||||
assert.equal(
|
||||
localizeSdkAgentMessage('ai.opencode.v2.startupTimeout', translate),
|
||||
'未能及时就绪',
|
||||
);
|
||||
assert.equal(localizeSdkAgentMessage('server password hunter2', translate), 'server password hunter2');
|
||||
assert.equal(localizeSdkAgentMessage('ai.opencode.v2.startupTimeout'), 'ai.opencode.v2.startupTimeout');
|
||||
});
|
||||
|
||||
test('upsertAgentActivity replaces streaming updates by stable item id', () => {
|
||||
const running: AgentActivity = {
|
||||
id: 'plan-1',
|
||||
|
||||
@@ -25,6 +25,17 @@ import {
|
||||
registerCodebuddyElicitation,
|
||||
} from '../../shared/codebuddyElicitations';
|
||||
|
||||
const OPENCODE_V2_MESSAGE_KEY = /^ai\.opencode\.v2\.[A-Za-z0-9]+$/;
|
||||
|
||||
export function localizeSdkAgentMessage(
|
||||
message: string,
|
||||
translate?: (key: string) => string,
|
||||
): string {
|
||||
if (!translate || !OPENCODE_V2_MESSAGE_KEY.test(message)) return message;
|
||||
const localized = translate(message);
|
||||
return localized || message;
|
||||
}
|
||||
|
||||
interface LiveExternalTurn {
|
||||
requestId: string;
|
||||
sessionId: string;
|
||||
@@ -333,7 +344,8 @@ async function runExternalTurn(
|
||||
},
|
||||
onWarning: (activity) => {
|
||||
flushTextBeforeNonTextEvent();
|
||||
runOrBufferUiOperation(() => updateActivity(activity));
|
||||
const message = localizeSdkAgentMessage(activity.message, ui.translate);
|
||||
runOrBufferUiOperation(() => updateActivity({ ...activity, message }));
|
||||
},
|
||||
onUsage: (usage: AgentUsage) => {
|
||||
flushTextBeforeNonTextEvent();
|
||||
@@ -391,7 +403,7 @@ async function runExternalTurn(
|
||||
},
|
||||
onError: (error: string) => {
|
||||
flushTextBeforeNonTextEvent();
|
||||
ui.reportStreamError(sessionId, signal, error);
|
||||
ui.reportStreamError(sessionId, signal, localizeSdkAgentMessage(error, ui.translate));
|
||||
ui.setStreamingForScope(sessionId, false);
|
||||
},
|
||||
onDone: () => {
|
||||
|
||||
@@ -3,7 +3,7 @@ import assert from 'node:assert/strict';
|
||||
import { createRequire } from 'node:module';
|
||||
import { fitLargeToolResultForModel } from './harness/toolResultFitting';
|
||||
import type { GroupConfig, Host, ManagedSource, PortForwardingRule, ProxyProfile, Snippet, VaultNote } from '../../domain/models';
|
||||
import { handleVaultAgentOp, runSerializedVaultAgentRequest, type VaultAgentApiDeps } from './vaultAgentBridgeClient';
|
||||
import { handleVaultAgentOp, runSerializedVaultAgentRequest, sanitizeHostForAgent, type VaultAgentApiDeps } from './vaultAgentBridgeClient';
|
||||
|
||||
type DepsSeed = {
|
||||
hosts?: Host[];
|
||||
@@ -404,6 +404,20 @@ describe('handleVaultAgentOp vault hosts', () => {
|
||||
assert.equal('password' in (hosts?.[0] ?? {}), false);
|
||||
});
|
||||
|
||||
it('sanitizeHostForAgent omits TOTP secrets', () => {
|
||||
const sanitized = sanitizeHostForAgent({
|
||||
id: 'host-1',
|
||||
label: 'prod',
|
||||
hostname: '10.0.0.1',
|
||||
username: 'root',
|
||||
port: 22,
|
||||
tags: [],
|
||||
os: 'linux',
|
||||
totp: { uri: 'otpauth://totp/Example:alice?secret=GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ' },
|
||||
});
|
||||
assert.equal('totp' in sanitized, false);
|
||||
});
|
||||
|
||||
it('host.open creates a terminal session for a vault host', async () => {
|
||||
const host: Host = {
|
||||
id: 'host-open-1',
|
||||
|
||||
@@ -79,6 +79,7 @@ const SENSITIVE_HOST_KEYS = new Set([
|
||||
'telnetPassword',
|
||||
'privateKey',
|
||||
'passphrase',
|
||||
'totp',
|
||||
]);
|
||||
|
||||
/**
|
||||
|
||||
@@ -301,6 +301,9 @@ export async function encryptHostSecrets(host: Host): Promise<Host> {
|
||||
if (out.proxyConfig?.password) {
|
||||
out.proxyConfig = { ...out.proxyConfig, password: await encryptField(out.proxyConfig.password) };
|
||||
}
|
||||
if (out.totp?.uri) {
|
||||
out.totp = { ...out.totp, uri: (await encryptField(out.totp.uri)) ?? out.totp.uri };
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
@@ -314,6 +317,12 @@ export async function decryptHostSecrets(host: Host): Promise<Host> {
|
||||
password: persistDecryptedSecret(out.proxyConfig.password, await decryptField(out.proxyConfig.password)),
|
||||
};
|
||||
}
|
||||
if (out.totp?.uri) {
|
||||
out.totp = {
|
||||
...out.totp,
|
||||
uri: persistDecryptedSecret(out.totp.uri, await decryptField(out.totp.uri)) ?? out.totp.uri,
|
||||
};
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
|
||||
@@ -15,6 +15,7 @@ import { isEncryptedCredentialPlaceholder, sanitizeCredentialValue } from '../..
|
||||
import { isPortForwardingAutoReconnectEnabled } from '../../domain/portForwardingReconnect';
|
||||
import { resolveBridgeKeyAuth, resolveBridgeSshAgentAuth, resolveHostAuth } from '../../domain/sshAuth';
|
||||
import { resolveHostKeepalive } from '../../domain/host';
|
||||
import { connectionTotpFields } from '../../domain/totpConfig';
|
||||
import { resolveHostSshConnectionTimeouts } from '../../domain/sshConnectionTimeouts';
|
||||
import {
|
||||
findIncompleteProxyIdentityId,
|
||||
@@ -998,6 +999,7 @@ export const startPortForward = async (
|
||||
authMethod: jumpResolved.authMethod,
|
||||
requiresMfa: !!jumpHost.requiresMfa,
|
||||
password: jumpPassword,
|
||||
...connectionTotpFields(jumpHost),
|
||||
privateKey: jumpKeyAuth.privateKey,
|
||||
certificate: jumpKey?.certificate,
|
||||
passphrase: jumpKeyAuth.passphrase,
|
||||
@@ -1133,6 +1135,7 @@ export const startPortForward = async (
|
||||
authMethod: resolved.authMethod,
|
||||
requiresMfa: !!host.requiresMfa,
|
||||
password,
|
||||
...connectionTotpFields(host),
|
||||
privateKey: keyAuth.privateKey,
|
||||
certificate: key?.certificate,
|
||||
keyId: resolved.keyId,
|
||||
|
||||
Generated
+8
-20
@@ -29,7 +29,9 @@
|
||||
"http-proxy-agent": "^7.0.2",
|
||||
"https-proxy-agent": "^7.0.6",
|
||||
"iconv-lite": "^0.6.3",
|
||||
"jsqr": "^1.4.0",
|
||||
"node-pty": "1.1.0",
|
||||
"re2js": "2.8.6",
|
||||
"serialport": "^13.0.0",
|
||||
"socks-proxy-agent": "^8.0.5",
|
||||
"ssh2-sftp-client": "^12.0.1",
|
||||
@@ -102,7 +104,6 @@
|
||||
"opencc-js": "^1.4.2",
|
||||
"patch-package": "^8.0.0",
|
||||
"pinyin-pro": "^3.28.1",
|
||||
"re2js": "2.8.6",
|
||||
"react": "^19.2.1",
|
||||
"react-dom": "^19.2.1",
|
||||
"react-test-renderer": "^19.2.4",
|
||||
@@ -5023,9 +5024,6 @@
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -5047,9 +5045,6 @@
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -5071,9 +5066,6 @@
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -5095,9 +5087,6 @@
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -5119,9 +5108,6 @@
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -5143,9 +5129,6 @@
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -14240,6 +14223,12 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/jsqr": {
|
||||
"version": "1.4.0",
|
||||
"resolved": "https://registry.npmjs.org/jsqr/-/jsqr-1.4.0.tgz",
|
||||
"integrity": "sha512-dxLob7q65Xg2DvstYkRpkYtmKm2sPJ9oFhrhmudT1dZvNFFTlroai3AWSpLey/w5vMcLBXRgOJsbXpdN9HzU/A==",
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/katex": {
|
||||
"version": "0.18.4",
|
||||
"resolved": "https://registry.npmjs.org/katex/-/katex-0.18.4.tgz",
|
||||
@@ -17628,7 +17617,6 @@
|
||||
"version": "2.8.6",
|
||||
"resolved": "https://registry.npmjs.org/re2js/-/re2js-2.8.6.tgz",
|
||||
"integrity": "sha512-xLgQil4kIUCrAzVk9fRSkxkFNwmygLFjVxXrLc65aE1F0+Zsb8rxumFBy4XKyvgMCTL6kilDq3EZ0piE2dP/Dg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
|
||||
+2
-1
@@ -93,7 +93,9 @@
|
||||
"http-proxy-agent": "^7.0.2",
|
||||
"https-proxy-agent": "^7.0.6",
|
||||
"iconv-lite": "^0.6.3",
|
||||
"jsqr": "^1.4.0",
|
||||
"node-pty": "1.1.0",
|
||||
"re2js": "2.8.6",
|
||||
"serialport": "^13.0.0",
|
||||
"socks-proxy-agent": "^8.0.5",
|
||||
"ssh2-sftp-client": "^12.0.1",
|
||||
@@ -163,7 +165,6 @@
|
||||
"opencc-js": "^1.4.2",
|
||||
"patch-package": "^8.0.0",
|
||||
"pinyin-pro": "^3.28.1",
|
||||
"re2js": "2.8.6",
|
||||
"react": "^19.2.1",
|
||||
"react-dom": "^19.2.1",
|
||||
"react-test-renderer": "^19.2.4",
|
||||
|
||||
@@ -54,6 +54,22 @@ test("build.files includes the single-channel SSH banner matcher", () => {
|
||||
assert.equal(banner.remoteSoftwareRequiresSingleChannel("JumpServer"), false);
|
||||
});
|
||||
|
||||
test("build.files includes the shared TOTP runtime required by SSH bridges", () => {
|
||||
assert.ok(
|
||||
config.files.includes("domain/totp.shared.cjs"),
|
||||
"packaged SSH bridges require domain/totp.shared.cjs at load",
|
||||
);
|
||||
assert.ok(
|
||||
config.files.includes("domain/totpPrompt.shared.cjs"),
|
||||
"the shared TOTP runtime requires the prompt exclusion list",
|
||||
);
|
||||
const totp = require("../domain/totp.shared.cjs");
|
||||
const prompts = require("../domain/totpPrompt.shared.cjs");
|
||||
assert.equal(typeof totp.generateTotp, "function");
|
||||
assert.equal(typeof totp.planTotpAutoSubmit, "function");
|
||||
assert.equal(totp.NON_TOTP_PROMPT, prompts.NON_TOTP_PROMPT);
|
||||
});
|
||||
|
||||
test("unpacked Tool CLI includes capability runtime dependencies", () => {
|
||||
assert.ok(
|
||||
config.asarUnpack.includes("electron/cli/**/*"),
|
||||
@@ -193,7 +209,6 @@ test("build.files excludes Vite-bundled renderer-only packages", () => {
|
||||
"!node_modules/@streamdown/**/*",
|
||||
"!node_modules/@tanstack/react-virtual/**/*",
|
||||
"!node_modules/pinyin-pro/**/*",
|
||||
"!node_modules/re2js/**/*",
|
||||
"!node_modules/@eslint-community/regexpp/**/*",
|
||||
"!node_modules/clsx/**/*",
|
||||
"!node_modules/tailwind-merge/**/*",
|
||||
|
||||
+24
@@ -157,6 +157,30 @@ declare global {
|
||||
credentialsAvailable?(): Promise<boolean>;
|
||||
credentialsEncrypt?(plaintext: string): Promise<string>;
|
||||
credentialsDecrypt?(value: string): Promise<string>;
|
||||
previewTotp?(payload: { text?: string; uri?: string }): Promise<{
|
||||
ok: boolean;
|
||||
uri?: string;
|
||||
code?: string;
|
||||
secondsRemaining?: number;
|
||||
period?: number;
|
||||
algorithm?: string;
|
||||
digits?: number;
|
||||
issuer?: string;
|
||||
label?: string;
|
||||
error?: string;
|
||||
}>;
|
||||
scanTotpQrFromScreen?(): Promise<{
|
||||
ok: boolean;
|
||||
uri?: string;
|
||||
code?: string;
|
||||
secondsRemaining?: number;
|
||||
period?: number;
|
||||
algorithm?: string;
|
||||
digits?: number;
|
||||
issuer?: string;
|
||||
label?: string;
|
||||
error?: string;
|
||||
}>;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+2
@@ -218,6 +218,7 @@ declare global {
|
||||
authMethod?: import("../../domain/models").HostAuthMethod;
|
||||
requiresMfa?: boolean;
|
||||
password?: string;
|
||||
totp?: NetcattyTotpConfig;
|
||||
privateKey?: string;
|
||||
certificate?: string;
|
||||
publicKey?: string;
|
||||
@@ -591,6 +592,7 @@ declare global {
|
||||
savedPassword?: string | null;
|
||||
/** When false, UI must not offer saving the response as the host password. */
|
||||
allowSavePassword?: boolean;
|
||||
totpUri?: string;
|
||||
scope?: "terminal" | "external";
|
||||
bootEpoch?: number;
|
||||
}) => void
|
||||
|
||||
Reference in new issue
Block a user