Compare commits

...
Author SHA1 Message Date
Cursor Agentand陈大猫 62eb8cbf42 fix(ai): restore the OpenCode 2 server username
The transport-sanitizer edit replaced the opencode username constant, so a real 2.x spawn threw ReferenceError before Basic auth. Put the constant back and lock the default client username.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-10-20 13:31:57 +00:00
Cursor Agentand陈大猫 f56cc29afe fix(ai): keep OpenCode transport mapping to mid-stream failures
Server-authored tool and session messages are no longer rewritten just because they match a transport string. A dead reply stream still reports the localized session-failed key.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-10-20 13:26:02 +00:00
Cursor Agentand陈大猫 0a50705bb5 fix(ai): drop undici transport text from OpenCode 2 details
HTTP error bodies and tool failures share the same sanitizer as session errors, so fetch failed, terminated, and other side closed never reach the chat.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-10-20 13:22:49 +00:00
Cursor Agentand陈大猫 24253d6406 fix(ai): keep caller OpenCode config and hide transport session errors
A profile's OPENCODE_CONFIG_CONTENT is merged under Netcatty's generated settings, and a server-reported fetch failure no longer reaches the chat as raw transport text.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-10-20 13:17:46 +00:00
Cursor Agentand陈大猫 17b113091c fix(ai): keep the OpenCode 2 config model as a qualified string
The selected provider/model stays the original string in OPENCODE_CONFIG_CONTENT. Session create still sends Model.Ref with id and providerID.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-10-20 13:10:28 +00:00
Cursor Agentand陈大猫 e47378614a fix(ai): ignore Bun version banners and hide undici transport errors
A crashed Bun binary was cached as OpenCode 1.x, and fetch or JSON-body failures still showed raw undici text. Skip Bun and Node.js runtime versions, and map those transport errors to the existing unavailable message.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-10-20 13:02:03 +00:00
Cursor Agentand陈大猫 3a190e6a9e fix(ai): localize OpenCode 2 failures when the server dies mid-turn
A killed event stream was surfacing undici's raw "terminated" text. Map that disconnect, a closed socket, and an SSE stream that ends before the turn finishes to the existing session-failed message.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-10-20 12:54:18 +00:00
Cursor Agentand陈大猫 c9e22779a1 fix(ai): refetch the OpenCode catalog when the settlement budget elapses during a read
A catalog read that outlasts the quiet-window budget still has to fetch the snapshot announced by an in-flight model or provider update.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-10-20 12:28:43 +00:00
Cursor Agentand陈大猫 7c278f1c55 fix(ai): refetch the OpenCode catalog when it changes during the first read
An update that arrives while the initial model list is in flight belongs to a newer snapshot. Record the event count before that request and fetch again when it moves.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-10-20 12:20:55 +00:00
Cursor Agentand陈大猫 0ce1eb88be fix(ai): refresh OpenCode version and catalog after startup races
Key the version cache by the resolved PATH binary and its mtime, so an in-place upgrade is not stuck on the previous major. While listing 2.x models, refetch after model.updated or provider.updated until the snapshot stays quiet.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-10-20 12:14:52 +00:00
Cursor Agentand陈大猫 ceea6f2758 fix(ai): post OpenCode 2 prompts after the event stream is open
The live /api/event stream does not replay. Wait until the server accepts the subscriber and start reading it before sending the user prompt.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-10-20 12:06:15 +00:00
Cursor Agentand陈大猫 6d911712a6 fix(ai): select OpenCode 2 catalog ids and keep context optional
Aliases expose provider/catalog id rather than the provider API model id. A rejected instruction entry warns and still sends the user prompt. The entry value stays the system-prompt string, which is valid JSON for that endpoint.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-10-20 11:59:28 +00:00
Cursor Agentand陈大猫 69a8b1a0a2 fix(ai): stop the OpenCode 2 event stream when a prompt is rejected
A rejected prompt does not emit a terminal SSE event. Abort that stream and close the server before waiting, and keep HTTP "not found" responses from being reported as a missing CLI.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-10-20 11:52:13 +00:00
Cursor Agentand陈大猫 24f390c332 feat(ai): support OpenCode 2.x while keeping the 1.x driver
Detect the installed opencode major version. 1.x stays on the bundled SDK and the old startup banner. 2.x uses the new serve banner, HTTP Basic auth, and the session API so model lists and chat turns work.

Fixes #3799

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-10-20 11:46:15 +00:00
cd7f182401 feat(ssh): host TOTP with QR scan and MFA auto-fill (#3323) (#3788)
* feat(ssh): add per-host TOTP with QR scan and MFA auto-fill

Store an optional otpauth TOTP secret on the host, encrypt and sync it
like a password, and auto-submit it once when a keyboard-interactive
prompt matches JumpServer or PAM TOTP text.

Closes #3323
Refs #3415

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>

* fix(ssh): address Codex TOTP review findings

Reset authenticator editor state when the host changes, match custom
prompts with RE2, offer keyboard-interactive during connection tests
when TOTP is configured, and drop the secret when a host becomes a plugin.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>

* fix(ssh): fill TOTP from the active prompt and ship re2js

Attach the connection's otpauth URI to the keyboard-interactive dialog
so Fill current code uses that attempt, including unsaved hosts. Move
re2js into production dependencies so packaged builds can match custom prompts.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>

* fix(ssh): ship the shared TOTP runtime in packaged builds

electron-builder allowlists domain CommonJS modules individually, and
sshAuthHelper requires domain/totp.shared.cjs at load.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>

* fix(ssh): save a pasted TOTP secret and ignore stale QR imports

Host save waits for the authenticator draft to validate before writing
the host. QR import completions from the previous host no longer change
busy state or errors on the newly selected host.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>

* test(host): ignore TOTP copy in the proxy command summary check

The assertion was matching the word secret in the authenticator UI.
It now checks that the ProxyCommand token itself is not rendered.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>

* feat(ssh): simplify host TOTP setup and type codes into the terminal

Move authenticator setup into a dialog so the host form keeps a single setting row, show keyboard-interactive prompts as field labels, and add a terminal context-menu action that types the current code without Enter.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>

* fix(ssh): keep TOTP fill inside the auth dialog

Remove the terminal context-menu action. Offer the verification-code button on each non-password prompt, highlight OTP prompts, and never write the code into the password field.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>

* fix(ssh): keep MFA instructions and scan every screen

Show keyboard-interactive name and instructions as plain text when they are not already the field label, and scan each unmatched desktop source once instead of repeating the first display.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>

* fix(ssh): keep multiline MFA prompt directions

Show lines before the last keyboard-interactive prompt as plain-text guidance, and keep that last line as the field label.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>

* fix(ssh): scan TOTP QR codes at each display's full size

Request a thumbnail as wide as the widest display and as tall as the tallest, so a portrait screen is not scaled into a landscape frame.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>

* fix(ssh): keep TOTP fill off menu prompts

Judge the fill action from the last prompt line and the shared menu, SMS, and confirmation exclusion list. Use a secondary button on the label row, and simplify the host authenticator row.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>

---------

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-10-20 19:22:05 +08:00
260b06540f fix(terminal): keep broadcast on during bash PS2 heredoc paste (#3798)
* fix(terminal): treat bare bash PS2 as a confirmed shell prompt

Broadcast input latched on the default ">" continuation prompt, so the
final Enter of a heredoc paste reached only the focused session.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>

* fix(terminal): trust bare PS2 only inside a heredoc

A global bare ">" confirmation cleared the sensitive-input latch for
echo-disabled prompts such as read -s -p '> '. Trust that line only when
the preceding command is a confirmed shell prompt containing <<.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>

* fix(terminal): coalesce a CRLF split across prompt-security chunks

A chunk that ends in CR completes the heredoc opener. The next chunk's
leading LF is the rest of that break, so dropping it keeps the opener
and a following bare PS2 stays trusted.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>

---------

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-10-20 19:20:19 +08:00
57089a3abf feat: temporary network device mode and tab-bar quick connect (#3793)
* feat(ssh): opt temporary connections into network device mode

Saved hosts and group-backed clones keep their own device type. SSH
connections with no saved host, including external launches and quick
connect, can start in network device mode when the new Terminal setting
is on. The setting stays off unless the user turns it on.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>

* feat(terminal): quick connect typed addresses from the tab switcher

The tab-bar quick switcher now offers the typed address when it already
parses as a connection target. Choosing that row opens the existing
quick connect wizard, including from a terminal tab where the vault
surface is hidden.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>

* fix(ssh): keep temporary Mosh and ET off network device mode

Those transports are stored as SSH plus a flag. Stamping network device
mode onto them made raw-host checks skip the copy-session working
directory probe. Plain temporary SSH still opts in.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>

* fix(ssh): read temporary connection settings at connect time

VaultView keeps a stale onConnect callback because its memo ignores that
prop. Read both temporary-connection toggles from a ref when connecting
so turning them on or off applies in the same session.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>

* fix(terminal): stop word queries from opening quick connect

Show the switcher quick-connect row only for address-like input, and
place it after matching hosts so Enter still connects to a saved host.
Translate the Hosts, Tabs, and New Workspace labels.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>

* fix(terminal): keep matching tabs ahead of quick connect

An address that matches an open session or workspace was still the
default Enter target when no saved host matched. Put that row after
those tabs so Enter switches to the existing one.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>

---------

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-10-20 18:29:41 +08:00
3c67392455 fix(ui): keep New Host menu icons from shrinking and fix toast contrast when app/system themes differ (#3795)
* fix(vault): keep the New Host local shell icon at 14px

The dropdown was a fixed w-44 flex row. "Local Shell (CMD)" is wider than
the sibling labels, so the icon SVG flex-shrunk below size 14 (and longer
shell names collapsed it entirely). Pin every menu icon with shrink-0 and
let the menu grow with the label.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>

* fix(ui): keep toast text on the app theme surface

Palette fills used a prefers-color-scheme dark variant, so an app-dark session with a light OS left light text on a pale background.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>

---------

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-10-20 18:14:11 +08:00
98 changed files with 5608 additions and 159 deletions

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\]/);
});
+11 -4
View File
@@ -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);
+12
View File
@@ -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 && (
+3
View File
@@ -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) => {
+8
View File
@@ -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',
+2
View File
@@ -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',
+2
View File
@@ -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',
+33
View File
@@ -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',
+2
View File
@@ -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',
+2
View File
@@ -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',
+33
View File
@@ -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);
});
+2
View File
@@ -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',
+2
View File
@@ -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-ключа',
+33
View File
@@ -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",
+8
View File
@@ -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',
+4
View File
@@ -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 密钥密码',
+29
View File
@@ -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': '身份不存在',
+8
View File
@@ -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',
+4
View File
@@ -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',
+5 -1
View File
@@ -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 金鑰密碼',
+29
View File
@@ -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': '身分不存在',
+35
View File
@@ -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();
});
+28
View File
@@ -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", () => {
+17 -1
View File
@@ -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}
+104
View File
@@ -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];
}
}
});
+568
View File
@@ -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}
</>
);
};
+81 -35
View File
@@ -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", () => {
+115 -39
View File
@@ -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
+2
View File
@@ -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}`,
});
+9 -1
View File
@@ -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, />新建工作区</);
});
+50 -9
View File
@@ -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
View File
@@ -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;
+9 -1
View File
@@ -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 ===
+14
View File
@@ -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,
+24
View File
@@ -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/);
});
+8 -4
View File
@@ -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 }) => {
+10 -9
View File
@@ -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>
+23
View File
@@ -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({
+15
View File
@@ -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;
});
+3
View File
@@ -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,
+60 -6
View File
@@ -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 } : {}),
};
}
+12
View File
@@ -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;
+9
View File
@@ -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
+26
View File
@@ -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);
+1
View File
@@ -125,6 +125,7 @@ export function stripBuiltInConnectionFieldsForPluginHost(host: Host): Host {
identityFileId: _identityFileId,
identityFilePaths: _identityFilePaths,
password: _password,
totp: _totp,
savePassword: _savePassword,
authMethod: _authMethod,
authPolicyVersion: _authPolicyVersion,
+24
View File
@@ -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);
});
+27
View File
@@ -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);
}
+12
View File
@@ -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);
+8
View File
@@ -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",
+213
View File
@@ -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,
);
});
+51 -1
View File
@@ -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 };
+72
View File
@@ -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) {
+101 -1
View File
@@ -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 };
+290
View File
@@ -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,
};
+231
View File
@@ -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");
});
+24
View File
@@ -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 } : {};
}
+7
View File
@@ -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;
+1
View File
@@ -0,0 +1 @@
export const NON_TOTP_PROMPT: RegExp;
+4
View File
@@ -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;
+6 -1
View File
@@ -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,
+46
View File
@@ -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);
});
+4 -2
View File
@@ -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,
+12 -7
View File
@@ -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) => {
+167
View File
@@ -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,
};
+140
View File
@@ -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");
});
+2
View File
@@ -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);
+1 -1
View File
@@ -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]";
+2
View File
@@ -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
View File
@@ -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,
+8 -20
View File
@@ -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
View File
@@ -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",
+16 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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