mirror of
https://github.com/binaricat/Netcatty.git
synced 2026-11-01 19:32:33 +00:00
Compare commits
9
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
680eb5eb0f | ||
|
|
df5c033dfa | ||
|
|
b5fee9632e | ||
|
|
fb69f4a48d | ||
|
|
1d8cba97ae | ||
|
|
88caf82e83 | ||
|
|
e3c45097c2 | ||
|
|
46de2023bd | ||
|
|
48f1a1cecf |
No files matched your search
@@ -74,6 +74,47 @@ jobs:
|
||||
- name: Build
|
||||
run: npm run build
|
||||
|
||||
windows-fido:
|
||||
name: windows-fido
|
||||
runs-on: windows-latest
|
||||
timeout-minutes: 20
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v7
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: 22
|
||||
cache: npm
|
||||
|
||||
- name: Install deps
|
||||
run: npm ci --ignore-scripts
|
||||
|
||||
- name: Apply production dependency patches
|
||||
run: npx patch-package --error-on-fail
|
||||
|
||||
- name: Install user-mode OpenSSH and software authenticator toolchain
|
||||
uses: msys2/setup-msys2@v2
|
||||
with:
|
||||
msystem: MSYS
|
||||
update: true
|
||||
install: gcc openssl-devel openssh
|
||||
|
||||
- name: Build disposable software authenticator
|
||||
shell: msys2 {0}
|
||||
run: |
|
||||
gcc -shared -DHAVE_STDINT_H scripts/fixtures/fido/provider.c -lcrypto -o /usr/lib/netcatty-fido-test-provider.dll
|
||||
printf 'NETCATTY_WINDOWS_SSH_AGENT=%s\n' "$(cygpath -w /usr/bin/ssh-agent.exe)" >> "$GITHUB_ENV"
|
||||
printf 'NETCATTY_WINDOWS_SSH_KEYGEN=%s\n' "$(cygpath -w /usr/bin/ssh-keygen.exe)" >> "$GITHUB_ENV"
|
||||
cygpath -w /usr/bin >> "$GITHUB_PATH"
|
||||
ssh -V
|
||||
|
||||
- name: Exercise Windows pipes, PIN bridge, SK signatures and cancellation
|
||||
env:
|
||||
NETCATTY_FIDO_WINDOWS: "1"
|
||||
run: node --test scripts/fido-windows.live.test.cjs electron/bridges/fidoNativeAgent.test.cjs electron/bridges/fidoAskpass.winpath.test.cjs
|
||||
|
||||
windows-broadcast-ssh:
|
||||
name: windows-broadcast-ssh
|
||||
runs-on: windows-latest
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { MonitorHostKeyDialog } from './components/vault/MonitorHostKeyDialog';
|
||||
import { useMonitorRuntime } from './application/state/useMonitorRuntime';
|
||||
import { MonitorRuntimeHost } from './application/app/MonitorRuntimeHost';
|
||||
import React, { useEffect } from 'react';
|
||||
|
||||
import { initializeFonts } from './application/state/fontStore';
|
||||
@@ -47,9 +47,9 @@ function SettingsI18nProvider({ children }: { children: React.ReactNode }) {
|
||||
* shell bags; AppSideEffects owns reactive effects + local UI publishing.
|
||||
*/
|
||||
function App() {
|
||||
useMonitorRuntime();
|
||||
return (
|
||||
<AppLocalStateProvider>
|
||||
<MonitorRuntimeHost />
|
||||
<AppSideEffects />
|
||||
<MonitorHostKeyDialog />
|
||||
<AppShell />
|
||||
|
||||
@@ -500,6 +500,20 @@ export function handlePassphraseSkipImpl(getCtx: AppContextGetter, requestId: st
|
||||
}
|
||||
}
|
||||
|
||||
export function handleFidoPromptSubmitImpl(getCtx: AppContextGetter, requestId: string, response: string) {
|
||||
const { netcattyBridge, setFidoPromptQueue } = getCtx();
|
||||
const bridge = netcattyBridge.get();
|
||||
void bridge?.respondFidoPrompt?.(requestId, response, false);
|
||||
setFidoPromptQueue((prev: { requestId: string }[]) => prev.filter((r) => r.requestId !== requestId));
|
||||
}
|
||||
|
||||
export function handleFidoPromptCancelImpl(getCtx: AppContextGetter, requestId: string) {
|
||||
const { netcattyBridge, setFidoPromptQueue } = getCtx();
|
||||
const bridge = netcattyBridge.get();
|
||||
void bridge?.respondFidoPrompt?.(requestId, '', true);
|
||||
setFidoPromptQueue((prev: { requestId: string }[]) => prev.filter((r) => r.requestId !== requestId));
|
||||
}
|
||||
|
||||
export function createLocalTerminalWithCurrentShellImpl(getCtx: AppContextGetter) {
|
||||
const { classifyLocalShellType, createLocalTerminal, discoveredShells, resolveShellSetting, terminalSettings } = getCtx();
|
||||
{
|
||||
|
||||
@@ -86,6 +86,7 @@ import { toast } from '../../components/ui/toast';
|
||||
import { VaultSection } from '../../components/VaultView';
|
||||
import { KeyboardInteractiveRequest } from '../../components/KeyboardInteractiveModal';
|
||||
import { PassphraseRequest } from '../../components/PassphraseModal';
|
||||
import type { FidoPromptRequest } from '../../components/FidoPromptModal';
|
||||
import { classifyLocalShellType } from '../../lib/localShell';
|
||||
import { useDiscoveredShells, resolveShellSetting, ensureDiscoveredShells } from '../../lib/useDiscoveredShells';
|
||||
import { Host, HostProtocol, KnownHost, SerialConfig, Snippet, SSHKey, TerminalSession } from '../../types';
|
||||
@@ -94,7 +95,7 @@ import { isScriptSnippet } from '../../domain/snippetScript.ts';
|
||||
import { collectSnippetDeleteIds } from '../../domain/snippetSelection.ts';
|
||||
import { shouldOpenLocalTerminalOnStartup, resolveStartupLandingSetting } from '../../domain/startupLanding';
|
||||
import { useAppStartupEffects } from './useAppStartupEffects';
|
||||
import { handleTrayJumpToSessionImpl, handleTrayTogglePortForwardImpl, handleTrayPanelConnectImpl, handleTrayPanelConnectRequestImpl, flushQueuedTrayPanelConnectHostsImpl, handleGlobalHotkeyKeyDownImpl, handleEscapeKeyDownImpl, handleKeyboardInteractiveSubmitImpl, handleKeyboardInteractiveCancelImpl, handlePassphraseSubmitImpl, handlePassphraseCancelImpl, handlePassphraseSkipImpl, createLocalTerminalWithCurrentShellImpl, splitSessionWithCurrentShellImpl, copySessionWithCurrentShellImpl, duplicateSessionWithCurrentShellImpl, copyWorkspaceWithCurrentShellImpl, copySessionToNewWindowWithCurrentShellImpl, confirmIfBusyLocalTerminalImpl, closeTabsBatchImpl, executeHotkeyActionImpl, handleCreateLocalTerminalImpl, handleConnectToHostImpl, handleTerminalDataCaptureImpl, hasMultipleProtocolsImpl, handleHostConnectWithProtocolCheckImpl, handleProtocolSelectImpl, handleRootContextMenuImpl, markForwardedNativeShortcutEvent } from './AppHandlers';
|
||||
import { handleTrayJumpToSessionImpl, handleTrayTogglePortForwardImpl, handleTrayPanelConnectImpl, handleTrayPanelConnectRequestImpl, flushQueuedTrayPanelConnectHostsImpl, handleGlobalHotkeyKeyDownImpl, handleEscapeKeyDownImpl, handleKeyboardInteractiveSubmitImpl, handleKeyboardInteractiveCancelImpl, handlePassphraseSubmitImpl, handlePassphraseCancelImpl, handlePassphraseSkipImpl, handleFidoPromptSubmitImpl, handleFidoPromptCancelImpl, createLocalTerminalWithCurrentShellImpl, splitSessionWithCurrentShellImpl, copySessionWithCurrentShellImpl, duplicateSessionWithCurrentShellImpl, copyWorkspaceWithCurrentShellImpl, copySessionToNewWindowWithCurrentShellImpl, confirmIfBusyLocalTerminalImpl, closeTabsBatchImpl, executeHotkeyActionImpl, handleCreateLocalTerminalImpl, handleConnectToHostImpl, handleTerminalDataCaptureImpl, hasMultipleProtocolsImpl, handleHostConnectWithProtocolCheckImpl, handleProtocolSelectImpl, handleRootContextMenuImpl, markForwardedNativeShortcutEvent } from './AppHandlers';
|
||||
|
||||
type OpenSessionInNewWindowPayload = {
|
||||
title?: string;
|
||||
@@ -139,6 +140,8 @@ export function AppSideEffects() {
|
||||
const [keyboardInteractiveQueue, setKeyboardInteractiveQueue] = useState<KeyboardInteractiveRequest[]>([]);
|
||||
// Passphrase request queue for encrypted SSH keys
|
||||
const [passphraseQueue, setPassphraseQueue] = useState<PassphraseRequest[]>([]);
|
||||
// FIDO2 PIN / touch prompt queue (OpenSSH sk-*)
|
||||
const [fidoPromptQueue, setFidoPromptQueue] = useState<FidoPromptRequest[]>([]);
|
||||
const [deleteHostConfirm, setDeleteHostConfirm] = useState<{ hostId: string; name: string } | null>(null);
|
||||
const [pendingNewWindowSession, setPendingNewWindowSession] = useState<OpenSessionInNewWindowPayload | null>(null);
|
||||
const [pendingTrayPanelConnectHostIds, setPendingTrayPanelConnectHostIds] = useState<string[]>([]);
|
||||
@@ -836,6 +839,48 @@ export function AppSideEffects() {
|
||||
// Handle passphrase skip (skip this key, continue with others)
|
||||
const handlePassphraseSkip = useCallback((requestId: string) => { return handlePassphraseSkipImpl(() => ({ netcattyBridge, requestId, setPassphraseQueue }), requestId); }, []);
|
||||
|
||||
// FIDO2 PIN / touch prompts from main-process askpass / sk-helper
|
||||
useEffect(() => {
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!bridge?.onFidoPromptRequest) return;
|
||||
const unsubscribe = bridge.onFidoPromptRequest((request) => {
|
||||
console.log('[App] FIDO prompt request:', request);
|
||||
setFidoPromptQueue((prev) => [...prev, {
|
||||
...request,
|
||||
kind: request.kind === 'touch' || request.kind === 'confirm' || request.kind === 'recovery' ? request.kind : 'pin',
|
||||
}]);
|
||||
});
|
||||
return () => { unsubscribe?.(); };
|
||||
}, []);
|
||||
|
||||
const handleFidoPromptSubmit = useCallback((requestId: string, response: string) => {
|
||||
if (response === 'fallback') toast.info(t('fido.recovery.passwordFallback'));
|
||||
return handleFidoPromptSubmitImpl(() => ({ netcattyBridge, requestId, setFidoPromptQueue }), requestId, response);
|
||||
}, [t]);
|
||||
|
||||
const handleFidoPromptCancel = useCallback((requestId: string) => {
|
||||
return handleFidoPromptCancelImpl(() => ({ netcattyBridge, requestId, setFidoPromptQueue }), requestId);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!bridge?.onFidoPromptTimeout) return;
|
||||
const unsubscribe = bridge.onFidoPromptTimeout((event) => {
|
||||
setFidoPromptQueue((prev) => prev.filter((r) => r.requestId !== event.requestId));
|
||||
toast.error(t('fido.prompt.timeout'));
|
||||
});
|
||||
return () => { unsubscribe?.(); };
|
||||
}, [t]);
|
||||
|
||||
useEffect(() => {
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!bridge?.onFidoPromptCancelled) return;
|
||||
const unsubscribe = bridge.onFidoPromptCancelled((event) => {
|
||||
setFidoPromptQueue((prev) => prev.filter((r) => r.requestId !== event.requestId));
|
||||
});
|
||||
return () => { unsubscribe?.(); };
|
||||
}, []);
|
||||
|
||||
// Handle passphrase timeout (request expired on backend)
|
||||
useEffect(() => {
|
||||
const bridge = netcattyBridge.get();
|
||||
@@ -1968,6 +2013,8 @@ export function AppSideEffects() {
|
||||
handlePassphraseCancel,
|
||||
handlePassphraseSkip,
|
||||
handlePassphraseSubmit,
|
||||
handleFidoPromptCancel,
|
||||
handleFidoPromptSubmit,
|
||||
handleProtocolSelect,
|
||||
handleRequestCloseEditorTabRef,
|
||||
resolveEmptyVaultConflict,
|
||||
@@ -1991,6 +2038,7 @@ export function AppSideEffects() {
|
||||
portForwardingRules,
|
||||
keyboardInteractiveQueue,
|
||||
passphraseQueue,
|
||||
fidoPromptQueue,
|
||||
deleteHostConfirm,
|
||||
vaultFocusRequest,
|
||||
openNoteRequest,
|
||||
@@ -2039,6 +2087,8 @@ export function AppSideEffects() {
|
||||
handlePassphraseCancel,
|
||||
handlePassphraseSkip,
|
||||
handlePassphraseSubmit,
|
||||
handleFidoPromptCancel,
|
||||
handleFidoPromptSubmit,
|
||||
handleProtocolSelect,
|
||||
resolveEmptyVaultConflict,
|
||||
handleCancelDeleteHost,
|
||||
@@ -2054,6 +2104,7 @@ export function AppSideEffects() {
|
||||
portForwardingRules,
|
||||
keyboardInteractiveQueue,
|
||||
passphraseQueue,
|
||||
fidoPromptQueue,
|
||||
deleteHostConfirm,
|
||||
vaultFocusRequest,
|
||||
openNoteRequest,
|
||||
|
||||
@@ -13,6 +13,7 @@ import { QuickScriptEditorDialog } from '../../components/scripts/QuickScriptEdi
|
||||
import { AddToWorkspaceDialog } from '../../components/workspace/AddToWorkspaceDialog';
|
||||
import { KeyboardInteractiveModal } from '../../components/KeyboardInteractiveModal';
|
||||
import { PassphraseModal } from '../../components/PassphraseModal';
|
||||
import { FidoPromptModal } from '../../components/FidoPromptModal';
|
||||
import { UnsavedChangesProvider, promptUnsavedChanges } from '../../components/editor/UnsavedChangesDialog';
|
||||
import { SnippetExecutionProvider } from '../../components/SnippetExecutionProvider';
|
||||
import { Button } from '../../components/ui/button';
|
||||
@@ -268,11 +269,11 @@ function AppViewInner({ domains }: AppViewProps) {
|
||||
followAppTerminalTheme,
|
||||
groupConfigs, handleAddKnownHost, handleConnectSerial, handleConnectToHost, handleCreateLocalTerminal, handleDefaultTerminalThemeChange, handleDeleteHost,
|
||||
handleEndSessionDrag, handleFollowAppTerminalThemeChange, handleHostConnectWithProtocolCheck, handleHotkeyAction, handleKeyboardInteractiveCancel, handleKeyboardInteractiveSubmit,
|
||||
handleOpenHostFromVaultNote, handleOpenQuickSwitcher, handleOpenSettings, handleOpenVaultHostFromChat, handleOpenVaultNoteFromChat, handleOpenVaultSectionFromChat, handleOpenVaultSnippetFromChat, handleRootContextMenu, handlePassphraseCancel, handlePassphraseSkip, handlePassphraseSubmit, handleProtocolSelect,
|
||||
handleOpenHostFromVaultNote, handleOpenQuickSwitcher, handleOpenSettings, handleOpenVaultHostFromChat, handleOpenVaultNoteFromChat, handleOpenVaultSectionFromChat, handleOpenVaultSnippetFromChat, handleRootContextMenu, handlePassphraseCancel, handlePassphraseSkip, handlePassphraseSubmit, handleFidoPromptSubmit, handleFidoPromptCancel, handleProtocolSelect,
|
||||
handleRequestCloseEditorTabRef, handleSessionStatusChange, handleSyncNowManual, handleTerminalDataCapture, handleUpdateHostFromTerminal,
|
||||
hostById, hosts, terminalHosts, updateTerminalHosts, hotkeyScheme, identities, importOrReuseKey, isBroadcastEnabled, isGlobalBroadcastEnabled, onToggleGlobalBroadcast, canUseGlobalBroadcast, isCreateWorkspaceOpen, isMacClient, isQuickSwitcherOpen,
|
||||
keyBindings, keyboardInteractiveQueue, keys, logViews, managedSources, navigateToSection, openLogView, openNoteRequest, orderedTabsWithEditors, orphanSessions,
|
||||
passphraseQueue, protocolSelectHost, proxyProfiles, portForwardingRules, quickResults, quickSearch, removeSessionFromWorkspace, reorderWorkTabs, reorderWorkspaceSessions,
|
||||
passphraseQueue, fidoPromptQueue, protocolSelectHost, proxyProfiles, portForwardingRules, quickResults, quickSearch, removeSessionFromWorkspace, reorderWorkTabs, reorderWorkspaceSessions,
|
||||
resolveEmptyVaultConflict, resolveSessionAppearance, runSnippet, sessionLogsDir, sessionLogsEnabled, sessionLogsFormat, sessionLogsTimestampsEnabled, sessionRenameTarget, sshDebugLogsEnabled,
|
||||
sessions, setActiveTabId, setDeepLinkHostDraft, setDraggingSessionId, setEditorWordWrap,
|
||||
setNavigateToSection, setTerminalFontFamilyId, setTerminalFontSize, setVaultFocusRequest, updateSessionFontSize, updateSessionRestoreCwd, updateSessionDynamicTitle, updateSessionCodingCliProvider, clearSessionFontSizeOverride,
|
||||
@@ -1037,6 +1038,13 @@ function AppViewInner({ domains }: AppViewProps) {
|
||||
onSkip={handlePassphraseSkip}
|
||||
/>
|
||||
|
||||
{/* FIDO2 PIN / touch presence (OpenSSH sk-*) */}
|
||||
<FidoPromptModal
|
||||
request={fidoPromptQueue?.[0] || null}
|
||||
onSubmit={handleFidoPromptSubmit}
|
||||
onCancel={handleFidoPromptCancel}
|
||||
/>
|
||||
|
||||
{/* Empty vault vs cloud data confirmation dialog (#679).
|
||||
This dialog intentionally cannot be dismissed — the user MUST
|
||||
choose "Restore" or "Keep Empty" before the sync flow can
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
import { useMonitorRuntime } from '../state/useMonitorRuntime';
|
||||
|
||||
/** Keep monitoring state and subscriptions outside the app coordinator. */
|
||||
export function MonitorRuntimeHost() {
|
||||
useMonitorRuntime();
|
||||
return null;
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import type { Host, PortForwardingRule } from '../../domain/models';
|
||||
import type { VaultSection } from '../../components/VaultView';
|
||||
import type { KeyboardInteractiveRequest } from '../../components/KeyboardInteractiveModal';
|
||||
import type { PassphraseRequest } from '../../components/PassphraseModal';
|
||||
import type { FidoPromptRequest } from '../../components/FidoPromptModal';
|
||||
|
||||
type Listener = () => void;
|
||||
|
||||
@@ -31,6 +32,7 @@ export type AppLocalUiSnapshot = {
|
||||
portForwardingRules: readonly PortForwardingRule[];
|
||||
keyboardInteractiveQueue: readonly KeyboardInteractiveRequest[];
|
||||
passphraseQueue: readonly PassphraseRequest[];
|
||||
fidoPromptQueue: readonly FidoPromptRequest[];
|
||||
deleteHostConfirm: { hostId: string; name: string } | null;
|
||||
vaultFocusRequest: unknown;
|
||||
openNoteRequest: unknown;
|
||||
@@ -49,6 +51,7 @@ export const EMPTY_APP_LOCAL_UI: AppLocalUiSnapshot = Object.freeze({
|
||||
portForwardingRules: Object.freeze([]) as readonly PortForwardingRule[],
|
||||
keyboardInteractiveQueue: Object.freeze([]) as readonly KeyboardInteractiveRequest[],
|
||||
passphraseQueue: Object.freeze([]) as readonly PassphraseRequest[],
|
||||
fidoPromptQueue: Object.freeze([]) as readonly FidoPromptRequest[],
|
||||
deleteHostConfirm: null,
|
||||
vaultFocusRequest: null,
|
||||
openNoteRequest: null,
|
||||
@@ -81,6 +84,7 @@ class AppLocalUiStore {
|
||||
&& prev.portForwardingRules === next.portForwardingRules
|
||||
&& prev.keyboardInteractiveQueue === next.keyboardInteractiveQueue
|
||||
&& prev.passphraseQueue === next.passphraseQueue
|
||||
&& prev.fidoPromptQueue === next.fidoPromptQueue
|
||||
&& prev.deleteHostConfirm === next.deleteHostConfirm
|
||||
&& prev.vaultFocusRequest === next.vaultFocusRequest
|
||||
&& prev.openNoteRequest === next.openNoteRequest
|
||||
|
||||
@@ -58,12 +58,15 @@ export function DialogsHost() {
|
||||
handlePassphraseCancel: handlers.handlePassphraseCancel,
|
||||
handlePassphraseSkip: handlers.handlePassphraseSkip,
|
||||
handlePassphraseSubmit: handlers.handlePassphraseSubmit,
|
||||
handleFidoPromptCancel: handlers.handleFidoPromptCancel,
|
||||
handleFidoPromptSubmit: handlers.handleFidoPromptSubmit,
|
||||
handleProtocolSelect: handlers.handleProtocolSelect,
|
||||
handleRequestCloseEditorTabRef: handlers.handleRequestCloseEditorTabRef,
|
||||
isCreateWorkspaceOpen: local.isCreateWorkspaceOpen,
|
||||
isQuickSwitcherOpen: local.isQuickSwitcherOpen,
|
||||
keyboardInteractiveQueue: local.keyboardInteractiveQueue,
|
||||
passphraseQueue: local.passphraseQueue,
|
||||
fidoPromptQueue: local.fidoPromptQueue,
|
||||
protocolSelectHost: local.protocolSelectHost,
|
||||
quickResults,
|
||||
quickSearch: local.quickSearch,
|
||||
@@ -83,6 +86,7 @@ export function DialogsHost() {
|
||||
local.isQuickSwitcherOpen,
|
||||
local.keyboardInteractiveQueue,
|
||||
local.passphraseQueue,
|
||||
local.fidoPromptQueue,
|
||||
local.protocolSelectHost,
|
||||
local.quickSearch,
|
||||
quickResults,
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import test from 'node:test';
|
||||
import vm from 'node:vm';
|
||||
import React, { useState } from 'react';
|
||||
import { act, create } from 'react-test-renderer';
|
||||
import ts from 'typescript';
|
||||
|
||||
function loadComponent(path: string, name: string, bindings: Record<string, unknown>) {
|
||||
const source = readFileSync(new URL(path, import.meta.url), 'utf8');
|
||||
const ast = ts.createSourceFile(path, source, ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX);
|
||||
const declaration = ast.statements.find((node) => ts.isFunctionDeclaration(node) && node.name?.text === name);
|
||||
assert.ok(declaration, `${name} must exist`);
|
||||
const code = ts.transpile(declaration.getText(ast).replace(/^export /, ''), {
|
||||
target: ts.ScriptTarget.ES2022, jsx: ts.JsxEmit.React,
|
||||
});
|
||||
return vm.runInNewContext(`${code}; ${name}`, { React, ...bindings }) as React.ComponentType;
|
||||
}
|
||||
|
||||
test('monitor controls update their runtime without rendering unrelated app siblings', async () => {
|
||||
const env = globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean };
|
||||
const previous = env.IS_REACT_ACT_ENVIRONMENT;
|
||||
env.IS_REACT_ACT_ENVIRONMENT = true;
|
||||
let updateMonitor: (() => void) | undefined;
|
||||
let runtimeRenders = 0;
|
||||
let sideEffectRenders = 0;
|
||||
let shellRenders = 0;
|
||||
const useMonitorRuntime = () => {
|
||||
const [, setVersion] = useState(0);
|
||||
updateMonitor = () => setVersion((version) => version + 1);
|
||||
runtimeRenders += 1;
|
||||
};
|
||||
const MonitorRuntimeHost = loadComponent('./MonitorRuntimeHost.tsx', 'MonitorRuntimeHost', { useMonitorRuntime });
|
||||
const App = loadComponent('../../App.tsx', 'App', {
|
||||
useMonitorRuntime,
|
||||
MonitorRuntimeHost,
|
||||
AppLocalStateProvider: ({ children }: { children: React.ReactNode }) => children,
|
||||
AppSideEffects: () => { sideEffectRenders += 1; return null; },
|
||||
MonitorHostKeyDialog: () => null,
|
||||
AppShell: React.memo(() => { shellRenders += 1; return null; }),
|
||||
});
|
||||
let renderer: ReturnType<typeof create> | undefined;
|
||||
try {
|
||||
await act(async () => { renderer = create(React.createElement(App)); });
|
||||
const initialRuntimeRenders = runtimeRenders;
|
||||
const initialSideEffectRenders = sideEffectRenders;
|
||||
const initialShellRenders = shellRenders;
|
||||
for (let i = 0; i < 10; i += 1) {
|
||||
await act(async () => updateMonitor!());
|
||||
}
|
||||
assert.equal(runtimeRenders - initialRuntimeRenders, 10, 'monitor updates must still run');
|
||||
assert.equal(sideEffectRenders - initialSideEffectRenders, 0, 'monitor-only state must not render AppSideEffects');
|
||||
assert.equal(shellRenders - initialShellRenders, 0, 'monitor controls must leave the shell stable');
|
||||
} finally {
|
||||
await act(async () => renderer?.unmount());
|
||||
env.IS_REACT_ACT_ENVIRONMENT = previous;
|
||||
}
|
||||
});
|
||||
@@ -24,7 +24,7 @@ export const enMonitorMessages = {
|
||||
"monitor.stopAll": "Stop all",
|
||||
"monitor.list": "List",
|
||||
"monitor.charts": "Charts",
|
||||
"monitor.search": "Search name, address or group",
|
||||
"monitor.search": "Search hosts, groups, tags or notes",
|
||||
"monitor.interval": "Update interval",
|
||||
"monitor.range": "Trend range",
|
||||
"monitor.seconds": "Every {count}s",
|
||||
@@ -34,6 +34,13 @@ export const enMonitorMessages = {
|
||||
"monitor.sort.name": "Name",
|
||||
"monitor.selectedCount": "Selected hosts: {count}",
|
||||
"monitor.noMatches": "No matching hosts",
|
||||
"monitor.latency": "Latency",
|
||||
"monitor.responseLatency": "Response latency (includes remote processing)",
|
||||
"monitor.tags": "Tags",
|
||||
"monitor.notes": "Notes",
|
||||
"monitor.visibleColumns": "Visible columns",
|
||||
"monitor.terminal": "Terminal",
|
||||
"monitor.resizeColumn": "Resize {column} column",
|
||||
"monitor.host": "Host",
|
||||
"monitor.hosts": "Hosts",
|
||||
"monitor.status": "Status",
|
||||
@@ -53,8 +60,6 @@ export const enMonitorMessages = {
|
||||
"monitor.memoryTrend": "Memory trend",
|
||||
"monitor.downloadTrend": "Download trend",
|
||||
"monitor.uploadTrend": "Upload trend",
|
||||
"monitor.memoryOnlyHint":
|
||||
"Readings stay in memory for up to 30 minutes and disappear when the app closes. Gaps mean no reading. Shared connections use the fastest active update interval.",
|
||||
"monitor.name": "Panel name",
|
||||
"monitor.description": "Description (optional)",
|
||||
"monitor.chooseHosts": "Choose hosts",
|
||||
|
||||
@@ -36,8 +36,6 @@ export const enScriptsMessages = {
|
||||
'scripts.sidePanel.library': 'Library',
|
||||
'scripts.sidePanel.running': 'Running',
|
||||
'scripts.sidePanel.newScript': 'New script',
|
||||
'scripts.sidePanel.viewList': 'List view',
|
||||
'scripts.sidePanel.viewStacked': 'Stacked view',
|
||||
'scripts.running.empty': 'No scripts are running in this session.',
|
||||
'scripts.running.unnamed': 'Untitled script',
|
||||
'scripts.running.status.running': 'Running',
|
||||
|
||||
@@ -637,6 +637,43 @@ export const enTerminalMessages: Messages = {
|
||||
'keychain.field.publicKey': 'Public key',
|
||||
'keychain.field.certificatePlaceholder': 'Certificate content (optional)',
|
||||
'keychain.generate.keyType': 'Key type',
|
||||
'keychain.generate.standardKeys': 'Standard keys',
|
||||
'keychain.generate.securityKeys': 'Security keys · FIDO2',
|
||||
'keychain.generate.residentDesc': 'Allow credentials to be recovered from this security key.',
|
||||
'keychain.generate.verifyRequiredDesc': 'Ask for the device PIN before signing.',
|
||||
'keychain.generate.generating': 'Generating…',
|
||||
'fido.prompt.pinTouchDesc': 'Enter your PIN, then touch the key to confirm.',
|
||||
'fido.prompt.details': 'Request details',
|
||||
'fido.prompt.showPin': 'Show PIN',
|
||||
'fido.prompt.hidePin': 'Hide PIN',
|
||||
'fido.prompt.verifying': 'Verifying…',
|
||||
'keychain.generate.fidoHint': 'Connect a FIDO2 security key. You may be asked for its PIN or a touch.',
|
||||
'fido.prompt.pinTitle': 'Security key PIN',
|
||||
'fido.prompt.touchTitle': 'Touch your security key',
|
||||
'fido.prompt.pinDesc': 'Enter your security key PIN to continue.',
|
||||
'fido.prompt.touchDesc': 'Touch your security key to confirm it is you.',
|
||||
'fido.prompt.pinLabel': 'PIN',
|
||||
'fido.prompt.touchWaiting': 'Waiting for your key…',
|
||||
'fido.prompt.touchAutomatic': 'Continues automatically after your touch',
|
||||
"fido.recovery.device-unavailable.title": "Connect the matching security key",
|
||||
"fido.recovery.device-unavailable.desc": "Connect the device containing this key's credential, then retry.",
|
||||
"fido.recovery.timeout.title": "Security key confirmation timed out",
|
||||
"fido.recovery.timeout.desc": "This device operation has stopped. Check that your key is connected, then retry and follow its touch or verification prompt.",
|
||||
"fido.recovery.pin-failed.title": "Security key PIN was not accepted",
|
||||
"fido.recovery.pin-failed.desc": "Check the FIDO2 PIN for this device before retrying. Repeated incorrect entries can lock the device.",
|
||||
"fido.recovery.failed.title": "Security key verification did not complete",
|
||||
"fido.recovery.failed.desc": "Check that the matching device is connected and supports this key. Retry, or cancel to check the device and OpenSSH configuration.",
|
||||
"fido.recovery.retry": "Retry verification",
|
||||
"fido.recovery.usePassword": "Use configured password",
|
||||
"fido.recovery.passwordFallback": "Trying the password configured for this host",
|
||||
"fido.prompt.cancelled": "Security key authentication cancelled.",
|
||||
'fido.prompt.touchDone': 'I touched it',
|
||||
'fido.prompt.timeout': 'FIDO prompt timed out. Try connecting again.',
|
||||
'fido.error.opensshMissing': 'OpenSSH with FIDO/libfido2 is required. On macOS: brew install openssh libfido2.',
|
||||
'fido.error.agentUnavailable': 'Could not start a FIDO-capable SSH agent.',
|
||||
'keychain.generate.resident': 'Store credentials on the key',
|
||||
'keychain.generate.verifyRequired': 'Verify PIN each time',
|
||||
'keychain.action.loadResident': 'Load resident keys from security key',
|
||||
'keychain.generate.keySize': 'Key size',
|
||||
'keychain.generate.labelPlaceholder': 'Key label',
|
||||
'keychain.generate.passphrasePlaceholder': 'Passphrase (optional)',
|
||||
@@ -905,4 +942,69 @@ export const enTerminalMessages: Messages = {
|
||||
'sftp.editor.saveAndClose': 'Save and close',
|
||||
'sftp.editor.quitBlockedByDirty': 'Unsaved editors — please save or discard before quitting',
|
||||
|
||||
'terminal.browser.newTab': 'New tab',
|
||||
'terminal.browser.address': 'Web address',
|
||||
'terminal.browser.back': 'Back',
|
||||
'terminal.browser.forward': 'Forward',
|
||||
'terminal.browser.reload': 'Reload',
|
||||
'terminal.browser.stop': 'Stop',
|
||||
'terminal.browser.bookmarks': 'Apps & bookmarks',
|
||||
'terminal.browser.downloads': 'Downloads',
|
||||
'terminal.browser.empty': 'Save an app or enter an address to get started.',
|
||||
'terminal.browser.connecting': 'Connecting…',
|
||||
'terminal.browser.disconnected': 'Connection unavailable',
|
||||
'terminal.browser.retry': 'Reconnect',
|
||||
'terminal.browser.addBookmark': 'Add app',
|
||||
'terminal.browser.editBookmark': 'Edit app',
|
||||
'terminal.browser.bookmarkTitle': 'Title',
|
||||
'terminal.browser.icon': 'Icon',
|
||||
'terminal.browser.website': 'Website',
|
||||
'terminal.browser.text': 'Text',
|
||||
'terminal.browser.image': 'Image',
|
||||
'terminal.browser.chooseImage': 'Choose image',
|
||||
'terminal.browser.color': 'Color',
|
||||
'terminal.browser.scope': 'Show on',
|
||||
'terminal.browser.hostScope': 'This host',
|
||||
'terminal.browser.allScope': 'All hosts',
|
||||
'terminal.browser.changeIcon': 'Change icon',
|
||||
'terminal.browser.resetIcon': 'Use website icon',
|
||||
'terminal.browser.downloadEmpty': 'No downloads yet',
|
||||
'terminal.browser.openFolder': 'Open downloads folder',
|
||||
'terminal.browser.clearDownloads': 'Clear finished downloads',
|
||||
'terminal.browser.cancelDownload': 'Cancel download',
|
||||
'terminal.browser.reveal': 'Show in folder',
|
||||
'terminal.browser.openFile': 'Open file',
|
||||
'terminal.browser.removeDownload': 'Remove from list',
|
||||
'terminal.browser.waiting': 'Preparing download…',
|
||||
'terminal.browser.progressing': 'Downloading',
|
||||
'terminal.browser.completed': 'Completed',
|
||||
'terminal.browser.cancelled': 'Cancelled',
|
||||
'terminal.browser.interrupted': 'Interrupted',
|
||||
'terminal.browser.missing': 'File moved or deleted',
|
||||
'terminal.browser.closeTab': 'Close tab',
|
||||
'terminal.browser.more': 'More actions',
|
||||
'terminal.browser.websiteIconHint': 'The website icon appears automatically after you visit it.',
|
||||
'terminal.browser.iconText': 'Icon text',
|
||||
'terminal.browser.iconTextHint': 'Use a letter, short name or emoji.',
|
||||
'terminal.browser.selectColor': 'Use {color}',
|
||||
'terminal.browser.customColor': 'Custom color',
|
||||
'terminal.browser.iconPreview': 'Icon preview',
|
||||
'terminal.browser.savedImage': 'Saved image',
|
||||
'terminal.browser.processingImage': 'Preparing image…',
|
||||
'terminal.browser.replaceImage': 'Replace image',
|
||||
'terminal.browser.removeImage': 'Remove image',
|
||||
'terminal.browser.uploadImage': 'Choose an image',
|
||||
'terminal.browser.dropImage': 'or drag and drop it here',
|
||||
'terminal.browser.imageHint': 'PNG, JPG, WebP, GIF or ICO · up to 5 MB. Resized automatically.',
|
||||
'terminal.browser.iconError.file-type': 'Choose a PNG, JPG, WebP, GIF or ICO image.',
|
||||
'terminal.browser.iconError.file-size': 'This image is too large. Choose a file under 5 MB.',
|
||||
'terminal.browser.iconError.decode': 'This image could not be read. Try another file.',
|
||||
'terminal.browser.bookmarkPreviewHint': 'Shown on your browser start page',
|
||||
'terminal.browser.untitledBookmark': 'Your app',
|
||||
'terminal.browser.bookmarkTitlePlaceholder': 'e.g. Team docs',
|
||||
'terminal.browser.websiteAddress': 'Website address',
|
||||
'terminal.browser.hostScopeHint': 'Show only in this host’s browser.',
|
||||
'terminal.browser.allScopeHint': 'Show in every host’s browser.',
|
||||
'terminal.browser.downloadLocation': 'Saved to your Downloads folder',
|
||||
'terminal.browser.saveAs': 'Save as…',
|
||||
};
|
||||
@@ -24,7 +24,7 @@ export const esMonitorMessages = {
|
||||
"monitor.stopAll": "Detener todo",
|
||||
"monitor.list": "Lista",
|
||||
"monitor.charts": "Gráficos",
|
||||
"monitor.search": "Buscar nombre, dirección o grupo",
|
||||
"monitor.search": "Buscar hosts, grupos, etiquetas o notas",
|
||||
"monitor.interval": "Intervalo de actualización",
|
||||
"monitor.range": "Rango de tendencias",
|
||||
"monitor.seconds": "Cada {count} s",
|
||||
@@ -34,6 +34,13 @@ export const esMonitorMessages = {
|
||||
"monitor.sort.name": "Nombre",
|
||||
"monitor.selectedCount": "Equipos seleccionados: {count}",
|
||||
"monitor.noMatches": "No hay equipos coincidentes",
|
||||
"monitor.latency": "Latencia",
|
||||
"monitor.responseLatency": "Latencia de respuesta (incluye procesamiento remoto)",
|
||||
"monitor.tags": "Etiquetas",
|
||||
"monitor.notes": "Notas",
|
||||
"monitor.visibleColumns": "Columnas visibles",
|
||||
"monitor.terminal": "Terminal",
|
||||
"monitor.resizeColumn": "Ajustar la columna {column}",
|
||||
"monitor.host": "Equipo",
|
||||
"monitor.hosts": "Equipos",
|
||||
"monitor.status": "Estado",
|
||||
@@ -53,8 +60,6 @@ export const esMonitorMessages = {
|
||||
"monitor.memoryTrend": "Tendencia de memoria",
|
||||
"monitor.downloadTrend": "Tendencia de descarga",
|
||||
"monitor.uploadTrend": "Tendencia de subida",
|
||||
"monitor.memoryOnlyHint":
|
||||
"Las lecturas se conservan en memoria hasta 30 minutos y desaparecen al cerrar. Los huecos indican datos ausentes. Las conexiones compartidas usan el intervalo activo más rápido.",
|
||||
"monitor.name": "Nombre del panel",
|
||||
"monitor.description": "Descripción (opcional)",
|
||||
"monitor.chooseHosts": "Elegir equipos",
|
||||
|
||||
@@ -36,8 +36,6 @@ export const esScriptsMessages = {
|
||||
'scripts.sidePanel.library': 'Biblioteca',
|
||||
'scripts.sidePanel.running': 'En ejecución',
|
||||
'scripts.sidePanel.newScript': 'Nuevo script',
|
||||
'scripts.sidePanel.viewList': 'Vista de lista',
|
||||
'scripts.sidePanel.viewStacked': 'Vista apilada',
|
||||
'scripts.running.empty': 'No hay scripts en ejecución en esta sesión.',
|
||||
'scripts.running.unnamed': 'Script sin título',
|
||||
'scripts.running.status.running': 'En ejecución',
|
||||
|
||||
@@ -904,4 +904,69 @@ export const esTerminalMessages: Messages = {
|
||||
'sftp.editor.saveAndClose': 'Guardar y cerrar',
|
||||
'sftp.editor.quitBlockedByDirty': 'Editores sin guardar: guarda o descarta antes de salir',
|
||||
|
||||
'terminal.browser.newTab': 'Nueva pestaña',
|
||||
'terminal.browser.address': 'Dirección web',
|
||||
'terminal.browser.back': 'Atrás',
|
||||
'terminal.browser.forward': 'Adelante',
|
||||
'terminal.browser.reload': 'Recargar',
|
||||
'terminal.browser.stop': 'Detener',
|
||||
'terminal.browser.bookmarks': 'Aplicaciones y marcadores',
|
||||
'terminal.browser.downloads': 'Descargas',
|
||||
'terminal.browser.empty': 'Añade una aplicación o introduce una dirección.',
|
||||
'terminal.browser.connecting': 'Conectando…',
|
||||
'terminal.browser.disconnected': 'Conexión no disponible',
|
||||
'terminal.browser.retry': 'Reconectar',
|
||||
'terminal.browser.addBookmark': 'Añadir aplicación',
|
||||
'terminal.browser.editBookmark': 'Editar aplicación',
|
||||
'terminal.browser.bookmarkTitle': 'Título',
|
||||
'terminal.browser.icon': 'Icono',
|
||||
'terminal.browser.website': 'Sitio web',
|
||||
'terminal.browser.text': 'Texto',
|
||||
'terminal.browser.image': 'Imagen',
|
||||
'terminal.browser.chooseImage': 'Elegir imagen',
|
||||
'terminal.browser.color': 'Color',
|
||||
'terminal.browser.scope': 'Mostrar en',
|
||||
'terminal.browser.hostScope': 'Este equipo',
|
||||
'terminal.browser.allScope': 'Todos los equipos',
|
||||
'terminal.browser.changeIcon': 'Cambiar icono',
|
||||
'terminal.browser.resetIcon': 'Usar icono del sitio',
|
||||
'terminal.browser.downloadEmpty': 'No hay descargas',
|
||||
'terminal.browser.openFolder': 'Abrir carpeta de descargas',
|
||||
'terminal.browser.clearDownloads': 'Borrar descargas finalizadas',
|
||||
'terminal.browser.cancelDownload': 'Cancelar descarga',
|
||||
'terminal.browser.reveal': 'Mostrar en carpeta',
|
||||
'terminal.browser.openFile': 'Abrir archivo',
|
||||
'terminal.browser.removeDownload': 'Quitar de la lista',
|
||||
'terminal.browser.waiting': 'Preparando descarga…',
|
||||
'terminal.browser.progressing': 'Descargando',
|
||||
'terminal.browser.completed': 'Completada',
|
||||
'terminal.browser.cancelled': 'Cancelada',
|
||||
'terminal.browser.interrupted': 'Interrumpida',
|
||||
'terminal.browser.missing': 'Archivo movido o eliminado',
|
||||
'terminal.browser.closeTab': 'Cerrar pestaña',
|
||||
'terminal.browser.more': 'Más acciones',
|
||||
'terminal.browser.websiteIconHint': 'El icono se obtiene automáticamente al visitar el sitio.',
|
||||
'terminal.browser.iconText': 'Texto del icono',
|
||||
'terminal.browser.iconTextHint': 'Usa una letra, abreviatura o emoji.',
|
||||
'terminal.browser.selectColor': 'Usar {color}',
|
||||
'terminal.browser.customColor': 'Color personalizado',
|
||||
'terminal.browser.iconPreview': 'Vista previa del icono',
|
||||
'terminal.browser.savedImage': 'Imagen guardada',
|
||||
'terminal.browser.processingImage': 'Preparando imagen…',
|
||||
'terminal.browser.replaceImage': 'Cambiar imagen',
|
||||
'terminal.browser.removeImage': 'Quitar imagen',
|
||||
'terminal.browser.uploadImage': 'Elegir imagen',
|
||||
'terminal.browser.dropImage': 'o arrástrala aquí',
|
||||
'terminal.browser.imageHint': 'PNG, JPG, WebP, GIF o ICO · hasta 5 MB. Se ajusta automáticamente.',
|
||||
'terminal.browser.iconError.file-type': 'Elige una imagen PNG, JPG, WebP, GIF o ICO.',
|
||||
'terminal.browser.iconError.file-size': 'La imagen es demasiado grande. Elige un archivo de hasta 5 MB.',
|
||||
'terminal.browser.iconError.decode': 'No se pudo leer la imagen. Prueba con otro archivo.',
|
||||
'terminal.browser.bookmarkPreviewHint': 'Aparece en la página de inicio del navegador',
|
||||
'terminal.browser.untitledBookmark': 'Tu aplicación',
|
||||
'terminal.browser.bookmarkTitlePlaceholder': 'p. ej., Documentos del equipo',
|
||||
'terminal.browser.websiteAddress': 'Dirección del sitio',
|
||||
'terminal.browser.hostScopeHint': 'Mostrar solo en el navegador de este host.',
|
||||
'terminal.browser.allScopeHint': 'Mostrar en el navegador de todos los hosts.',
|
||||
'terminal.browser.downloadLocation': 'Guardado en la carpeta Descargas',
|
||||
'terminal.browser.saveAs': 'Guardar como…',
|
||||
};
|
||||
@@ -23,7 +23,7 @@ export const ruMonitorMessages = {
|
||||
"monitor.stopAll": "Остановить всё",
|
||||
"monitor.list": "Список",
|
||||
"monitor.charts": "Графики",
|
||||
"monitor.search": "Имя, адрес или группа",
|
||||
"monitor.search": "Поиск узлов, групп, тегов и заметок",
|
||||
"monitor.interval": "Интервал обновления",
|
||||
"monitor.range": "Период графика",
|
||||
"monitor.seconds": "Каждые {count} с",
|
||||
@@ -33,6 +33,13 @@ export const ruMonitorMessages = {
|
||||
"monitor.sort.name": "Имя",
|
||||
"monitor.selectedCount": "Выбрано серверов: {count}",
|
||||
"monitor.noMatches": "Нет подходящих серверов",
|
||||
"monitor.latency": "Задержка",
|
||||
"monitor.responseLatency": "Задержка ответа (включая обработку на сервере)",
|
||||
"monitor.tags": "Теги",
|
||||
"monitor.notes": "Заметки",
|
||||
"monitor.visibleColumns": "Видимые столбцы",
|
||||
"monitor.terminal": "Терминал",
|
||||
"monitor.resizeColumn": "Изменить ширину столбца «{column}»",
|
||||
"monitor.host": "Сервер",
|
||||
"monitor.hosts": "Серверы",
|
||||
"monitor.status": "Состояние",
|
||||
@@ -52,8 +59,6 @@ export const ruMonitorMessages = {
|
||||
"monitor.memoryTrend": "График памяти",
|
||||
"monitor.downloadTrend": "График приёма",
|
||||
"monitor.uploadTrend": "График передачи",
|
||||
"monitor.memoryOnlyHint":
|
||||
"Измерения хранятся в памяти до 30 минут и удаляются при выходе. Пробелы означают отсутствие данных. Общие соединения используют наименьший активный интервал.",
|
||||
"monitor.name": "Название панели",
|
||||
"monitor.description": "Описание (необязательно)",
|
||||
"monitor.chooseHosts": "Выбрать серверы",
|
||||
|
||||
@@ -36,8 +36,6 @@ export const ruScriptsMessages = {
|
||||
'scripts.sidePanel.library': 'Библиотека',
|
||||
'scripts.sidePanel.running': 'Выполняются',
|
||||
'scripts.sidePanel.newScript': 'Новый скрипт',
|
||||
'scripts.sidePanel.viewList': 'Список',
|
||||
'scripts.sidePanel.viewStacked': 'Плитка',
|
||||
'scripts.running.empty': 'В этой сессии не выполняются скрипты.',
|
||||
'scripts.running.unnamed': 'Скрипт без названия',
|
||||
'scripts.running.status.running': 'Выполняется',
|
||||
|
||||
@@ -652,6 +652,43 @@ export const ruTerminalMessages: Messages = {
|
||||
'keychain.field.publicKey': 'Публичный ключ',
|
||||
'keychain.field.certificatePlaceholder': 'Содержимое сертификата (необязательно)',
|
||||
'keychain.generate.keyType': 'Тип ключа',
|
||||
'keychain.generate.standardKeys': 'Обычные ключи',
|
||||
'keychain.generate.securityKeys': 'Ключи безопасности · FIDO2',
|
||||
'keychain.generate.residentDesc': 'Позволить восстановить учётные данные с ключа безопасности.',
|
||||
'keychain.generate.verifyRequiredDesc': 'Запрашивать PIN устройства перед подписью.',
|
||||
'keychain.generate.generating': 'Создание…',
|
||||
'fido.prompt.pinTouchDesc': 'Введите PIN, затем коснитесь ключа для подтверждения.',
|
||||
'fido.prompt.details': 'Подробности запроса',
|
||||
'fido.prompt.showPin': 'Показать PIN',
|
||||
'fido.prompt.hidePin': 'Скрыть PIN',
|
||||
'fido.prompt.verifying': 'Проверка…',
|
||||
'keychain.generate.fidoHint': 'Подключите ключ FIDO2. При создании может потребоваться PIN или касание.',
|
||||
'fido.prompt.pinTitle': 'PIN ключа безопасности',
|
||||
'fido.prompt.touchTitle': 'Коснитесь ключа безопасности',
|
||||
'fido.prompt.pinDesc': 'Введите PIN ключа безопасности, чтобы продолжить.',
|
||||
'fido.prompt.touchDesc': 'Коснитесь ключа безопасности, чтобы подтвердить свою личность.',
|
||||
'fido.prompt.pinLabel': 'PIN',
|
||||
'fido.prompt.touchWaiting': 'Ожидание ответа ключа…',
|
||||
'fido.prompt.touchAutomatic': 'Продолжим автоматически после касания',
|
||||
"fido.recovery.device-unavailable.title": "Подключите нужный ключ безопасности",
|
||||
"fido.recovery.device-unavailable.desc": "Подключите устройство с учётными данными этого ключа и повторите попытку.",
|
||||
"fido.recovery.timeout.title": "Время подтверждения ключа истекло",
|
||||
"fido.recovery.timeout.desc": "Операция устройства остановлена. Проверьте подключение ключа, повторите попытку и подтвердите её на устройстве.",
|
||||
"fido.recovery.pin-failed.title": "PIN ключа безопасности не принят",
|
||||
"fido.recovery.pin-failed.desc": "Проверьте PIN FIDO2 этого устройства. Повторные ошибки могут заблокировать устройство.",
|
||||
"fido.recovery.failed.title": "Проверка ключа не завершена",
|
||||
"fido.recovery.failed.desc": "Проверьте подключение нужного устройства и поддержку ключа. Повторите попытку или отмените её и проверьте настройки OpenSSH.",
|
||||
"fido.recovery.retry": "Повторить проверку",
|
||||
"fido.recovery.usePassword": "Использовать сохранённый пароль",
|
||||
"fido.recovery.passwordFallback": "Вход с паролем, настроенным для этого хоста",
|
||||
"fido.prompt.cancelled": "Проверка ключа безопасности отменена.",
|
||||
'fido.prompt.touchDone': 'Я коснулся',
|
||||
'fido.prompt.timeout': 'Время ожидания FIDO истекло. Попробуйте снова.',
|
||||
'fido.error.opensshMissing': 'Нужен OpenSSH с FIDO/libfido2. macOS: brew install openssh libfido2.',
|
||||
'fido.error.agentUnavailable': 'Не удалось запустить SSH agent с FIDO.',
|
||||
'keychain.generate.resident': 'Хранить учётные данные на ключе',
|
||||
'keychain.generate.verifyRequired': 'Проверять PIN каждый раз',
|
||||
'keychain.action.loadResident': 'Загрузить resident-ключи с устройства',
|
||||
'keychain.generate.keySize': 'Размер ключа',
|
||||
'keychain.generate.labelPlaceholder': 'Метка ключа',
|
||||
'keychain.generate.passphrasePlaceholder': 'Парольная фраза (необязательно)',
|
||||
@@ -912,4 +949,69 @@ export const ruTerminalMessages: Messages = {
|
||||
'sftp.editor.saveAndClose': 'Сохранить и закрыть',
|
||||
'sftp.editor.quitBlockedByDirty': 'Есть несохранённые редакторы — перед выходом сохраните изменения или отбросьте их',
|
||||
|
||||
'terminal.browser.newTab': 'Новая вкладка',
|
||||
'terminal.browser.address': 'Веб-адрес',
|
||||
'terminal.browser.back': 'Назад',
|
||||
'terminal.browser.forward': 'Вперёд',
|
||||
'terminal.browser.reload': 'Обновить',
|
||||
'terminal.browser.stop': 'Остановить',
|
||||
'terminal.browser.bookmarks': 'Приложения и закладки',
|
||||
'terminal.browser.downloads': 'Загрузки',
|
||||
'terminal.browser.empty': 'Добавьте приложение или введите адрес.',
|
||||
'terminal.browser.connecting': 'Подключение…',
|
||||
'terminal.browser.disconnected': 'Соединение недоступно',
|
||||
'terminal.browser.retry': 'Подключиться снова',
|
||||
'terminal.browser.addBookmark': 'Добавить приложение',
|
||||
'terminal.browser.editBookmark': 'Изменить приложение',
|
||||
'terminal.browser.bookmarkTitle': 'Название',
|
||||
'terminal.browser.icon': 'Значок',
|
||||
'terminal.browser.website': 'Значок сайта',
|
||||
'terminal.browser.text': 'Текст',
|
||||
'terminal.browser.image': 'Изображение',
|
||||
'terminal.browser.chooseImage': 'Выбрать изображение',
|
||||
'terminal.browser.color': 'Цвет',
|
||||
'terminal.browser.scope': 'Показывать на',
|
||||
'terminal.browser.hostScope': 'Этот хост',
|
||||
'terminal.browser.allScope': 'Все хосты',
|
||||
'terminal.browser.changeIcon': 'Изменить значок',
|
||||
'terminal.browser.resetIcon': 'Использовать значок сайта',
|
||||
'terminal.browser.downloadEmpty': 'Загрузок пока нет',
|
||||
'terminal.browser.openFolder': 'Открыть папку загрузок',
|
||||
'terminal.browser.clearDownloads': 'Очистить завершённые загрузки',
|
||||
'terminal.browser.cancelDownload': 'Отменить загрузку',
|
||||
'terminal.browser.reveal': 'Показать в папке',
|
||||
'terminal.browser.openFile': 'Открыть файл',
|
||||
'terminal.browser.removeDownload': 'Удалить из списка',
|
||||
'terminal.browser.waiting': 'Подготовка загрузки…',
|
||||
'terminal.browser.progressing': 'Загрузка',
|
||||
'terminal.browser.completed': 'Завершена',
|
||||
'terminal.browser.cancelled': 'Отменена',
|
||||
'terminal.browser.interrupted': 'Прервана',
|
||||
'terminal.browser.missing': 'Файл перемещён или удалён',
|
||||
'terminal.browser.closeTab': 'Закрыть вкладку',
|
||||
'terminal.browser.more': 'Другие действия',
|
||||
'terminal.browser.websiteIconHint': 'Значок загрузится автоматически после посещения сайта.',
|
||||
'terminal.browser.iconText': 'Текст значка',
|
||||
'terminal.browser.iconTextHint': 'Буква, сокращение или эмодзи.',
|
||||
'terminal.browser.selectColor': 'Выбрать {color}',
|
||||
'terminal.browser.customColor': 'Свой цвет',
|
||||
'terminal.browser.iconPreview': 'Предпросмотр значка',
|
||||
'terminal.browser.savedImage': 'Сохранённое изображение',
|
||||
'terminal.browser.processingImage': 'Обработка изображения…',
|
||||
'terminal.browser.replaceImage': 'Заменить',
|
||||
'terminal.browser.removeImage': 'Удалить изображение',
|
||||
'terminal.browser.uploadImage': 'Выбрать изображение',
|
||||
'terminal.browser.dropImage': 'или перетащить его сюда',
|
||||
'terminal.browser.imageHint': 'PNG, JPG, WebP, GIF или ICO · до 5 МБ. Размер подбирается автоматически.',
|
||||
'terminal.browser.iconError.file-type': 'Выберите PNG, JPG, WebP, GIF или ICO.',
|
||||
'terminal.browser.iconError.file-size': 'Изображение слишком большое. Выберите файл до 5 МБ.',
|
||||
'terminal.browser.iconError.decode': 'Не удалось прочитать изображение. Выберите другой файл.',
|
||||
'terminal.browser.bookmarkPreviewHint': 'Отображается на начальной странице браузера',
|
||||
'terminal.browser.untitledBookmark': 'Ваше приложение',
|
||||
'terminal.browser.bookmarkTitlePlaceholder': 'Например, Документы команды',
|
||||
'terminal.browser.websiteAddress': 'Адрес сайта',
|
||||
'terminal.browser.hostScopeHint': 'Только в браузере текущего хоста.',
|
||||
'terminal.browser.allScopeHint': 'В браузерах всех хостов.',
|
||||
'terminal.browser.downloadLocation': 'Файлы сохраняются в папку «Загрузки»',
|
||||
'terminal.browser.saveAs': 'Сохранить как…',
|
||||
};
|
||||
@@ -1,16 +0,0 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import en from './en.ts';
|
||||
import ru from './ru.ts';
|
||||
import es from './es.ts';
|
||||
import zhCN from './zh-CN.ts';
|
||||
import zhTW from './zh-TW.ts';
|
||||
|
||||
test('scripts side panel view-toggle copy exists in every locale', () => {
|
||||
const KEYS = ['scripts.sidePanel.viewList', 'scripts.sidePanel.viewStacked'];
|
||||
for (const [locale, messages] of Object.entries({ en, ru, es, zhCN, zhTW })) {
|
||||
const missing = KEYS.filter((key) => !(messages as Record<string, string>)[key]);
|
||||
assert.deepEqual(missing, [], `${locale} is missing scripts view-toggle copy`);
|
||||
}
|
||||
});
|
||||
@@ -22,7 +22,7 @@ export const zhCNMonitorMessages = {
|
||||
"monitor.stopAll": "全部停止",
|
||||
"monitor.list": "列表",
|
||||
"monitor.charts": "图表",
|
||||
"monitor.search": "搜索名称、地址或分组",
|
||||
"monitor.search": "搜索主机、分组、标签或备注",
|
||||
"monitor.interval": "更新频率",
|
||||
"monitor.range": "趋势范围",
|
||||
"monitor.seconds": "每 {count} 秒",
|
||||
@@ -32,6 +32,13 @@ export const zhCNMonitorMessages = {
|
||||
"monitor.sort.name": "名称",
|
||||
"monitor.selectedCount": "已选 {count} 台主机",
|
||||
"monitor.noMatches": "没有符合条件的主机",
|
||||
"monitor.latency": "延迟",
|
||||
"monitor.responseLatency": "响应延迟(含远端处理)",
|
||||
"monitor.tags": "标签",
|
||||
"monitor.notes": "备注",
|
||||
"monitor.visibleColumns": "显示的列",
|
||||
"monitor.terminal": "终端",
|
||||
"monitor.resizeColumn": "调整「{column}」列宽",
|
||||
"monitor.host": "主机",
|
||||
"monitor.hosts": "主机",
|
||||
"monitor.status": "状态",
|
||||
@@ -51,8 +58,6 @@ export const zhCNMonitorMessages = {
|
||||
"monitor.memoryTrend": "内存趋势",
|
||||
"monitor.downloadTrend": "下载趋势",
|
||||
"monitor.uploadTrend": "上传趋势",
|
||||
"monitor.memoryOnlyHint":
|
||||
"读数只保留在内存中,最多 30 分钟,退出后清空。空白表示没有读数。共享连接按当前最快的更新需求采集。",
|
||||
"monitor.name": "面板名称",
|
||||
"monitor.description": "说明(可选)",
|
||||
"monitor.chooseHosts": "选择主机",
|
||||
|
||||
@@ -36,8 +36,6 @@ export const zhCNScriptsMessages = {
|
||||
'scripts.sidePanel.library': '脚本库',
|
||||
'scripts.sidePanel.running': '运行中',
|
||||
'scripts.sidePanel.newScript': '新建脚本',
|
||||
'scripts.sidePanel.viewList': '列表视图',
|
||||
'scripts.sidePanel.viewStacked': '堆叠视图',
|
||||
'scripts.running.empty': '当前会话没有正在运行的脚本。',
|
||||
'scripts.running.unnamed': '未命名脚本',
|
||||
'scripts.running.status.running': '运行中',
|
||||
|
||||
@@ -716,13 +716,50 @@ export const zhCNTerminalMessages: Messages = {
|
||||
'keychain.export.scriptRequired': '脚本 *',
|
||||
'keychain.export.exportToHost': '导出到主机',
|
||||
'keychain.export.failedGeneric': '导出失败:{message}',
|
||||
'keychain.field.label': 'Label',
|
||||
'keychain.field.labelRequired': 'Label *',
|
||||
'keychain.field.label': '名称',
|
||||
'keychain.field.labelRequired': '名称 *',
|
||||
'keychain.field.labelPlaceholder': '密钥 Label',
|
||||
'keychain.field.privateKeyRequired': '私钥 *',
|
||||
'keychain.field.publicKey': '公钥',
|
||||
'keychain.field.certificatePlaceholder': '证书内容(可选)',
|
||||
'keychain.generate.keyType': '密钥类型',
|
||||
'keychain.generate.standardKeys': '标准密钥',
|
||||
'keychain.generate.securityKeys': '安全密钥 · FIDO2',
|
||||
'keychain.generate.residentDesc': '允许从安全密钥中找回凭据。',
|
||||
'keychain.generate.verifyRequiredDesc': '签名前验证设备 PIN,增加一层保护。',
|
||||
'keychain.generate.generating': '正在生成…',
|
||||
'fido.prompt.pinTouchDesc': '输入 PIN 后,触摸安全密钥确认身份。',
|
||||
'fido.prompt.details': '查看请求详情',
|
||||
'fido.prompt.showPin': '显示 PIN',
|
||||
'fido.prompt.hidePin': '隐藏 PIN',
|
||||
'fido.prompt.verifying': '正在验证…',
|
||||
'keychain.generate.fidoHint': '请连接 FIDO2 安全密钥,生成时按提示输入 PIN 或触摸设备。',
|
||||
'fido.prompt.pinTitle': '安全密钥 PIN',
|
||||
'fido.prompt.touchTitle': '请触摸安全密钥',
|
||||
'fido.prompt.pinDesc': '输入安全密钥的 PIN 以继续。',
|
||||
'fido.prompt.touchDesc': '轻触安全密钥,确认是你本人在操作。',
|
||||
'fido.prompt.pinLabel': 'PIN',
|
||||
'fido.prompt.touchWaiting': '等待安全密钥响应…',
|
||||
'fido.prompt.touchAutomatic': '触摸后将自动继续',
|
||||
"fido.recovery.device-unavailable.title": "请连接对应的安全密钥",
|
||||
"fido.recovery.device-unavailable.desc": "请连接保存了此密钥凭据的设备,然后重试。",
|
||||
"fido.recovery.timeout.title": "等待安全密钥确认超时",
|
||||
"fido.recovery.timeout.desc": "本次设备操作已停止。请确认设备仍已连接,重试后按提示触摸或完成设备验证。",
|
||||
"fido.recovery.pin-failed.title": "安全密钥 PIN 未通过验证",
|
||||
"fido.recovery.pin-failed.desc": "请先检查此设备的 FIDO2 PIN,再重试。连续输错 PIN 可能导致设备锁定。",
|
||||
"fido.recovery.failed.title": "安全密钥验证未完成",
|
||||
"fido.recovery.failed.desc": "请确认对应设备已连接,并支持当前密钥。你可以重试,或取消后检查设备和 OpenSSH 配置。",
|
||||
"fido.recovery.retry": "重试验证",
|
||||
"fido.recovery.usePassword": "改用已配置的密码",
|
||||
"fido.recovery.passwordFallback": "正在使用此主机已配置的密码登录",
|
||||
"fido.prompt.cancelled": "已取消安全密钥验证。",
|
||||
'fido.prompt.touchDone': '已触摸',
|
||||
'fido.prompt.timeout': 'FIDO 提示已超时,请重新连接。',
|
||||
'fido.error.opensshMissing': '需要带 FIDO/libfido2 的 OpenSSH。macOS:brew install openssh libfido2。',
|
||||
'fido.error.agentUnavailable': '无法启动支持 FIDO 的 SSH agent。',
|
||||
'keychain.generate.resident': '在设备上保存凭据',
|
||||
'keychain.generate.verifyRequired': '每次验证 PIN',
|
||||
'keychain.action.loadResident': '从安全密钥加载驻留密钥',
|
||||
'keychain.generate.keySize': '密钥长度',
|
||||
'keychain.generate.labelPlaceholder': '密钥 Label',
|
||||
'keychain.generate.passphrasePlaceholder': 'Passphrase(可选)',
|
||||
@@ -991,4 +1028,69 @@ export const zhCNTerminalMessages: Messages = {
|
||||
'sftp.editor.saveAndClose': '保存并关闭',
|
||||
'sftp.editor.quitBlockedByDirty': '存在未保存的编辑器,请先处理后再退出',
|
||||
|
||||
'terminal.browser.newTab': '新标签页',
|
||||
'terminal.browser.address': '输入网址',
|
||||
'terminal.browser.back': '后退',
|
||||
'terminal.browser.forward': '前进',
|
||||
'terminal.browser.reload': '刷新',
|
||||
'terminal.browser.stop': '停止',
|
||||
'terminal.browser.bookmarks': '应用与书签',
|
||||
'terminal.browser.downloads': '下载',
|
||||
'terminal.browser.empty': '添加应用或输入网址即可开始浏览。',
|
||||
'terminal.browser.connecting': '正在连接…',
|
||||
'terminal.browser.disconnected': '连接不可用',
|
||||
'terminal.browser.retry': '重新连接',
|
||||
'terminal.browser.addBookmark': '添加应用',
|
||||
'terminal.browser.editBookmark': '编辑应用',
|
||||
'terminal.browser.bookmarkTitle': '名称',
|
||||
'terminal.browser.icon': '图标',
|
||||
'terminal.browser.website': '网站图标',
|
||||
'terminal.browser.text': '文字',
|
||||
'terminal.browser.image': '图片',
|
||||
'terminal.browser.chooseImage': '选择图片',
|
||||
'terminal.browser.color': '颜色',
|
||||
'terminal.browser.scope': '显示范围',
|
||||
'terminal.browser.hostScope': '此主机',
|
||||
'terminal.browser.allScope': '所有主机',
|
||||
'terminal.browser.changeIcon': '更改图标',
|
||||
'terminal.browser.resetIcon': '使用网站图标',
|
||||
'terminal.browser.downloadEmpty': '暂无下载',
|
||||
'terminal.browser.openFolder': '打开下载文件夹',
|
||||
'terminal.browser.clearDownloads': '清除已结束的下载',
|
||||
'terminal.browser.cancelDownload': '取消下载',
|
||||
'terminal.browser.reveal': '在文件夹中显示',
|
||||
'terminal.browser.openFile': '打开文件',
|
||||
'terminal.browser.removeDownload': '从列表移除',
|
||||
'terminal.browser.waiting': '正在准备下载…',
|
||||
'terminal.browser.progressing': '正在下载',
|
||||
'terminal.browser.completed': '已完成',
|
||||
'terminal.browser.cancelled': '已取消',
|
||||
'terminal.browser.interrupted': '已中断',
|
||||
'terminal.browser.missing': '文件已移动或删除',
|
||||
'terminal.browser.closeTab': '关闭标签页',
|
||||
'terminal.browser.more': '更多操作',
|
||||
'terminal.browser.websiteIconHint': '访问网站后,自动获取并显示网站图标。',
|
||||
'terminal.browser.iconText': '图标文字',
|
||||
'terminal.browser.iconTextHint': '可以使用字母、简称或表情。',
|
||||
'terminal.browser.selectColor': '使用 {color}',
|
||||
'terminal.browser.customColor': '自定义颜色',
|
||||
'terminal.browser.iconPreview': '图标预览',
|
||||
'terminal.browser.savedImage': '已保存的图片',
|
||||
'terminal.browser.processingImage': '正在处理图片…',
|
||||
'terminal.browser.replaceImage': '替换图片',
|
||||
'terminal.browser.removeImage': '移除图片',
|
||||
'terminal.browser.uploadImage': '选择图片',
|
||||
'terminal.browser.dropImage': '也可以将图片拖到这里',
|
||||
'terminal.browser.imageHint': '支持 PNG、JPG、WebP、GIF、ICO,最大 5 MB,自动适配图标大小。',
|
||||
'terminal.browser.iconError.file-type': '请选择 PNG、JPG、WebP、GIF 或 ICO 图片。',
|
||||
'terminal.browser.iconError.file-size': '图片太大,请选择不超过 5 MB 的文件。',
|
||||
'terminal.browser.iconError.decode': '无法读取这张图片,请换一个文件。',
|
||||
'terminal.browser.bookmarkPreviewHint': '显示在浏览器开始页',
|
||||
'terminal.browser.untitledBookmark': '应用预览',
|
||||
'terminal.browser.bookmarkTitlePlaceholder': '例如,团队文档',
|
||||
'terminal.browser.websiteAddress': '网址',
|
||||
'terminal.browser.hostScopeHint': '仅在当前主机的浏览器中显示。',
|
||||
'terminal.browser.allScopeHint': '在每台主机的浏览器中显示。',
|
||||
'terminal.browser.downloadLocation': '文件保存在系统“下载”文件夹',
|
||||
'terminal.browser.saveAs': '另存为…',
|
||||
};
|
||||
@@ -22,7 +22,7 @@ export const zhTWMonitorMessages = {
|
||||
"monitor.stopAll": "全部停止",
|
||||
"monitor.list": "列表",
|
||||
"monitor.charts": "圖表",
|
||||
"monitor.search": "搜尋名稱、位址或群組",
|
||||
"monitor.search": "搜尋主機、群組、標籤或備註",
|
||||
"monitor.interval": "更新頻率",
|
||||
"monitor.range": "趨勢範圍",
|
||||
"monitor.seconds": "每 {count} 秒",
|
||||
@@ -32,6 +32,13 @@ export const zhTWMonitorMessages = {
|
||||
"monitor.sort.name": "名稱",
|
||||
"monitor.selectedCount": "已選 {count} 台主機",
|
||||
"monitor.noMatches": "沒有符合條件的主機",
|
||||
"monitor.latency": "延遲",
|
||||
"monitor.responseLatency": "回應延遲(含遠端處理)",
|
||||
"monitor.tags": "標籤",
|
||||
"monitor.notes": "備註",
|
||||
"monitor.visibleColumns": "顯示的欄位",
|
||||
"monitor.terminal": "終端",
|
||||
"monitor.resizeColumn": "調整「{column}」欄寬",
|
||||
"monitor.host": "主機",
|
||||
"monitor.hosts": "主機",
|
||||
"monitor.status": "狀態",
|
||||
@@ -51,8 +58,6 @@ export const zhTWMonitorMessages = {
|
||||
"monitor.memoryTrend": "記憶體趨勢",
|
||||
"monitor.downloadTrend": "下載趨勢",
|
||||
"monitor.uploadTrend": "上傳趨勢",
|
||||
"monitor.memoryOnlyHint":
|
||||
"讀數只保留在記憶體中,最多 30 分鐘,結束後清空。空白表示沒有讀數。共用連線依目前最快的更新需求採集。",
|
||||
"monitor.name": "面板名稱",
|
||||
"monitor.description": "說明(選填)",
|
||||
"monitor.chooseHosts": "選擇主機",
|
||||
|
||||
@@ -36,8 +36,6 @@ export const zhTWScriptsMessages = {
|
||||
'scripts.sidePanel.library': '指令碼庫',
|
||||
'scripts.sidePanel.running': '執行中',
|
||||
'scripts.sidePanel.newScript': '新增指令碼',
|
||||
'scripts.sidePanel.viewList': '列表檢視',
|
||||
'scripts.sidePanel.viewStacked': '堆疊檢視',
|
||||
'scripts.running.empty': '目前工作階段沒有正在執行的指令碼。',
|
||||
'scripts.running.unnamed': '未命名指令碼',
|
||||
'scripts.running.status.running': '執行中',
|
||||
|
||||
@@ -716,13 +716,50 @@ export const zhTWTerminalMessages: Messages = {
|
||||
'keychain.export.scriptRequired': '指令碼 *',
|
||||
'keychain.export.exportToHost': '匯出到主機',
|
||||
'keychain.export.failedGeneric': '匯出失敗:{message}',
|
||||
'keychain.field.label': 'Label',
|
||||
'keychain.field.labelRequired': 'Label *',
|
||||
'keychain.field.label': '名稱',
|
||||
'keychain.field.labelRequired': '名稱 *',
|
||||
'keychain.field.labelPlaceholder': '金鑰 Label',
|
||||
'keychain.field.privateKeyRequired': '私鑰 *',
|
||||
'keychain.field.publicKey': '公鑰',
|
||||
'keychain.field.certificatePlaceholder': '憑證內容(可選)',
|
||||
'keychain.generate.keyType': '金鑰型別',
|
||||
'keychain.generate.standardKeys': '標準金鑰',
|
||||
'keychain.generate.securityKeys': '安全金鑰 · FIDO2',
|
||||
'keychain.generate.residentDesc': '允許從安全金鑰中找回憑證。',
|
||||
'keychain.generate.verifyRequiredDesc': '簽章前驗證裝置 PIN,多一層保護。',
|
||||
'keychain.generate.generating': '正在產生…',
|
||||
'fido.prompt.pinTouchDesc': '輸入 PIN 後,觸碰安全金鑰確認身分。',
|
||||
'fido.prompt.details': '檢視請求詳情',
|
||||
'fido.prompt.showPin': '顯示 PIN',
|
||||
'fido.prompt.hidePin': '隱藏 PIN',
|
||||
'fido.prompt.verifying': '正在驗證…',
|
||||
'keychain.generate.fidoHint': '請連接 FIDO2 安全金鑰,產生時依提示輸入 PIN 或觸碰裝置。',
|
||||
'fido.prompt.pinTitle': '安全金鑰 PIN',
|
||||
'fido.prompt.touchTitle': '請觸摸安全金鑰',
|
||||
'fido.prompt.pinDesc': '輸入安全金鑰的 PIN 以繼續。',
|
||||
'fido.prompt.touchDesc': '輕觸安全金鑰,確認是你本人操作。',
|
||||
'fido.prompt.pinLabel': 'PIN',
|
||||
'fido.prompt.touchWaiting': '等待安全金鑰回應…',
|
||||
'fido.prompt.touchAutomatic': '觸摸後將自動繼續',
|
||||
"fido.recovery.device-unavailable.title": "請連接對應的安全金鑰",
|
||||
"fido.recovery.device-unavailable.desc": "請連接儲存了此金鑰憑證的裝置,然後重試。",
|
||||
"fido.recovery.timeout.title": "等待安全金鑰確認逾時",
|
||||
"fido.recovery.timeout.desc": "本次裝置操作已停止。請確認裝置仍已連接,重試後依提示觸摸或完成裝置驗證。",
|
||||
"fido.recovery.pin-failed.title": "安全金鑰 PIN 未通過驗證",
|
||||
"fido.recovery.pin-failed.desc": "請先檢查此裝置的 FIDO2 PIN,再重試。連續輸錯 PIN 可能導致裝置鎖定。",
|
||||
"fido.recovery.failed.title": "安全金鑰驗證未完成",
|
||||
"fido.recovery.failed.desc": "請確認對應裝置已連接,並支援目前的金鑰。你可以重試,或取消後檢查裝置和 OpenSSH 設定。",
|
||||
"fido.recovery.retry": "重試驗證",
|
||||
"fido.recovery.usePassword": "改用已設定的密碼",
|
||||
"fido.recovery.passwordFallback": "正在使用此主機已設定的密碼登入",
|
||||
"fido.prompt.cancelled": "已取消安全金鑰驗證。",
|
||||
'fido.prompt.touchDone': '已觸摸',
|
||||
'fido.prompt.timeout': 'FIDO 提示已逾時,請重新連線。',
|
||||
'fido.error.opensshMissing': '需要支援 FIDO/libfido2 的 OpenSSH。macOS:brew install openssh libfido2。',
|
||||
'fido.error.agentUnavailable': '無法啟動支援 FIDO 的 SSH agent。',
|
||||
'keychain.generate.resident': '在裝置上儲存憑證',
|
||||
'keychain.generate.verifyRequired': '每次驗證 PIN',
|
||||
'keychain.action.loadResident': '從安全金鑰載入駐留金鑰',
|
||||
'keychain.generate.keySize': '金鑰長度',
|
||||
'keychain.generate.labelPlaceholder': '金鑰 Label',
|
||||
'keychain.generate.passphrasePlaceholder': 'Passphrase(可選)',
|
||||
@@ -991,4 +1028,69 @@ export const zhTWTerminalMessages: Messages = {
|
||||
'sftp.editor.saveAndClose': '儲存並關閉',
|
||||
'sftp.editor.quitBlockedByDirty': '存在未儲存的編輯器,請先處理後再離開',
|
||||
|
||||
'terminal.browser.newTab': '新增分頁',
|
||||
'terminal.browser.address': '輸入網址',
|
||||
'terminal.browser.back': '上一頁',
|
||||
'terminal.browser.forward': '下一頁',
|
||||
'terminal.browser.reload': '重新整理',
|
||||
'terminal.browser.stop': '停止',
|
||||
'terminal.browser.bookmarks': '應用程式與書籤',
|
||||
'terminal.browser.downloads': '下載',
|
||||
'terminal.browser.empty': '新增應用程式或輸入網址即可開始瀏覽。',
|
||||
'terminal.browser.connecting': '正在連線…',
|
||||
'terminal.browser.disconnected': '連線無法使用',
|
||||
'terminal.browser.retry': '重新連線',
|
||||
'terminal.browser.addBookmark': '新增應用程式',
|
||||
'terminal.browser.editBookmark': '編輯應用程式',
|
||||
'terminal.browser.bookmarkTitle': '名稱',
|
||||
'terminal.browser.icon': '圖示',
|
||||
'terminal.browser.website': '網站圖示',
|
||||
'terminal.browser.text': '文字',
|
||||
'terminal.browser.image': '圖片',
|
||||
'terminal.browser.chooseImage': '選擇圖片',
|
||||
'terminal.browser.color': '顏色',
|
||||
'terminal.browser.scope': '顯示範圍',
|
||||
'terminal.browser.hostScope': '此主機',
|
||||
'terminal.browser.allScope': '所有主機',
|
||||
'terminal.browser.changeIcon': '變更圖示',
|
||||
'terminal.browser.resetIcon': '使用網站圖示',
|
||||
'terminal.browser.downloadEmpty': '尚無下載',
|
||||
'terminal.browser.openFolder': '開啟下載資料夾',
|
||||
'terminal.browser.clearDownloads': '清除已結束的下載',
|
||||
'terminal.browser.cancelDownload': '取消下載',
|
||||
'terminal.browser.reveal': '在資料夾中顯示',
|
||||
'terminal.browser.openFile': '開啟檔案',
|
||||
'terminal.browser.removeDownload': '從清單移除',
|
||||
'terminal.browser.waiting': '正在準備下載…',
|
||||
'terminal.browser.progressing': '正在下載',
|
||||
'terminal.browser.completed': '已完成',
|
||||
'terminal.browser.cancelled': '已取消',
|
||||
'terminal.browser.interrupted': '已中斷',
|
||||
'terminal.browser.missing': '檔案已移動或刪除',
|
||||
'terminal.browser.closeTab': '關閉分頁',
|
||||
'terminal.browser.more': '更多操作',
|
||||
'terminal.browser.websiteIconHint': '造訪網站後,自動取得並顯示網站圖示。',
|
||||
'terminal.browser.iconText': '圖示文字',
|
||||
'terminal.browser.iconTextHint': '可以使用字母、簡稱或表情符號。',
|
||||
'terminal.browser.selectColor': '使用 {color}',
|
||||
'terminal.browser.customColor': '自訂顏色',
|
||||
'terminal.browser.iconPreview': '圖示預覽',
|
||||
'terminal.browser.savedImage': '已儲存的圖片',
|
||||
'terminal.browser.processingImage': '正在處理圖片…',
|
||||
'terminal.browser.replaceImage': '替換圖片',
|
||||
'terminal.browser.removeImage': '移除圖片',
|
||||
'terminal.browser.uploadImage': '選擇圖片',
|
||||
'terminal.browser.dropImage': '也可以將圖片拖到這裡',
|
||||
'terminal.browser.imageHint': '支援 PNG、JPG、WebP、GIF、ICO,最大 5 MB,自動調整圖示大小。',
|
||||
'terminal.browser.iconError.file-type': '請選擇 PNG、JPG、WebP、GIF 或 ICO 圖片。',
|
||||
'terminal.browser.iconError.file-size': '圖片太大,請選擇不超過 5 MB 的檔案。',
|
||||
'terminal.browser.iconError.decode': '無法讀取這張圖片,請更換檔案。',
|
||||
'terminal.browser.bookmarkPreviewHint': '顯示在瀏覽器起始頁',
|
||||
'terminal.browser.untitledBookmark': '應用程式預覽',
|
||||
'terminal.browser.bookmarkTitlePlaceholder': '例如,團隊文件',
|
||||
'terminal.browser.websiteAddress': '網址',
|
||||
'terminal.browser.hostScopeHint': '僅在目前主機的瀏覽器中顯示。',
|
||||
'terminal.browser.allScopeHint': '在每台主機的瀏覽器中顯示。',
|
||||
'terminal.browser.downloadLocation': '檔案儲存在系統「下載項目」資料夾',
|
||||
'terminal.browser.saveAs': '另存新檔…',
|
||||
};
|
||||
@@ -0,0 +1,49 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import {
|
||||
publishMonitorSnapshot,
|
||||
getMonitorHistoryCursor,
|
||||
} from "./monitorRuntimeStore";
|
||||
import type { MonitorSnapshot } from "../../domain/monitorRuntime";
|
||||
|
||||
test("renderer cursor acknowledges GPU publication separately from CPU sample and history", () => {
|
||||
const snapshot: MonitorSnapshot = {
|
||||
historyMode: "replace",
|
||||
runtimeId: "gpu-cursor-test",
|
||||
epoch: 1,
|
||||
takenAt: 1000,
|
||||
running: ["panel"],
|
||||
count: 1,
|
||||
hostCount: 1,
|
||||
activeSessions: [],
|
||||
startedAt: { panel: 0 },
|
||||
hosts: {
|
||||
host: {
|
||||
historyId: "history",
|
||||
status: "live",
|
||||
at: 1000,
|
||||
statsAt: 1001,
|
||||
points: [
|
||||
{ at: 1000, cpu: 10, memory: 20, disk: null, rx: null, tx: null },
|
||||
],
|
||||
},
|
||||
},
|
||||
};
|
||||
publishMonitorSnapshot(snapshot);
|
||||
assert.deepEqual(getMonitorHistoryCursor().after.host, {
|
||||
at: 1000,
|
||||
statsAt: 1001,
|
||||
historyId: "history",
|
||||
});
|
||||
publishMonitorSnapshot({
|
||||
...snapshot,
|
||||
historyMode: "append",
|
||||
takenAt: 2000,
|
||||
hosts: { host: { ...snapshot.hosts.host, statsAt: 2001, points: [] } },
|
||||
});
|
||||
assert.deepEqual(getMonitorHistoryCursor().after.host, {
|
||||
at: 1000,
|
||||
statsAt: 2001,
|
||||
historyId: "history",
|
||||
});
|
||||
});
|
||||
@@ -22,7 +22,7 @@ export function getMonitorHistoryCursor(): MonitorHistoryCursor {
|
||||
return {
|
||||
runtimeId: snapshot.runtimeId,
|
||||
epoch: snapshot.epoch,
|
||||
after: Object.fromEntries(Object.entries(snapshot.hosts).map(([id, host]) => [id, { at: host.points.at(-1)?.at ?? 0, statsAt: host.at, historyId: host.historyId }])),
|
||||
after: Object.fromEntries(Object.entries(snapshot.hosts).map(([id, host]) => [id, { at: host.points.at(-1)?.at ?? 0, statsAt: host.statsAt ?? host.at, historyId: host.historyId }])),
|
||||
};
|
||||
}
|
||||
export function publishMonitorUnavailable() {
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import React from 'react';
|
||||
import { act, create } from 'react-test-renderer';
|
||||
import { installDomEnvironment } from '../../components/test-support/renderReactDom';
|
||||
import { netcattyBridge } from '../../infrastructure/services/netcattyBridge';
|
||||
import type { HostBrowserPanelEvent, HostBrowserState } from '../../domain/hostBrowser';
|
||||
import { useHostBrowserControls } from './useHostBrowserControls';
|
||||
|
||||
const state = (label: string): HostBrowserState => ({ hostId: label, label, route: 'connected', error: null, activeTabId: null, tabs: [] });
|
||||
|
||||
test('browser events stay scoped and a late initial read cannot overwrite a newer page', async (t) => {
|
||||
const env = installDomEnvironment();
|
||||
let resolveInitial: (value: HostBrowserState) => void = () => assert.fail('initial read missing');
|
||||
let emit: (event: HostBrowserPanelEvent) => void = () => {};
|
||||
let unsubscribed = false;
|
||||
t.mock.method(netcattyBridge, 'require', () => ({
|
||||
getHostBrowserPanelState: () => new Promise<HostBrowserState>((resolve) => { resolveInitial = resolve; }),
|
||||
commandHostBrowserPanel: async () => state('command'),
|
||||
listHostBrowserPanelBookmarks: async () => [],
|
||||
listHostBrowserPanelDownloads: async () => [],
|
||||
onHostBrowserPanelEvent: (callback: typeof emit) => { emit = callback; return () => { unsubscribed = true; }; },
|
||||
}));
|
||||
let controls: ReturnType<typeof useHostBrowserControls>;
|
||||
function Panel() { controls = useHostBrowserControls('browser-a', true); return null; }
|
||||
let renderer: ReturnType<typeof create>;
|
||||
t.after(async () => { if (renderer) await act(async () => renderer.unmount()); env.cleanup(); });
|
||||
await act(async () => { renderer = create(React.createElement(Panel)); });
|
||||
await act(async () => emit({ browserId: 'browser-b', type: 'state', state: state('wrong host') }));
|
||||
assert.equal(controls.state, null);
|
||||
await act(async () => emit({ browserId: 'browser-a', type: 'state', state: state('new page') }));
|
||||
await act(async () => resolveInitial(state('old page')));
|
||||
assert.equal(controls.state?.label, 'new page');
|
||||
await act(async () => renderer.unmount());
|
||||
assert.equal(unsubscribed, true);
|
||||
});
|
||||
|
||||
test('switching browser contexts invalidates an outstanding navigation response', async (t) => {
|
||||
const env = installDomEnvironment();
|
||||
let finish: (value: HostBrowserState) => void = () => assert.fail('command missing');
|
||||
const commands: string[] = [];
|
||||
t.mock.method(netcattyBridge, 'require', () => ({
|
||||
getHostBrowserPanelState: async (id: string) => state(id),
|
||||
commandHostBrowserPanel: (id: string) => { commands.push(id); return new Promise<HostBrowserState>((resolve) => { finish = resolve; }); },
|
||||
listHostBrowserPanelBookmarks: async () => [], listHostBrowserPanelDownloads: async () => [],
|
||||
onHostBrowserPanelEvent: () => () => {},
|
||||
}));
|
||||
let controls: ReturnType<typeof useHostBrowserControls>;
|
||||
function Panel({ id }: { id: string }) { controls = useHostBrowserControls(id, true); return null; }
|
||||
let renderer: ReturnType<typeof create>;
|
||||
t.after(async () => { if (renderer) await act(async () => renderer.unmount()); env.cleanup(); });
|
||||
await act(async () => { renderer = create(React.createElement(Panel, { id: 'browser-a' })); });
|
||||
let pending: Promise<boolean>;
|
||||
await act(async () => { pending = controls.command({ type: 'navigate', url: 'http://localhost/' }); });
|
||||
await act(async () => renderer.update(React.createElement(Panel, { id: 'browser-b' })));
|
||||
await act(async () => finish(state('late browser-a')));
|
||||
assert.equal(await pending, false);
|
||||
assert.equal(controls.state?.label, 'browser-b');
|
||||
assert.deepEqual(commands, ['browser-a']);
|
||||
});
|
||||
@@ -0,0 +1,95 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import type { BrowserBookmark } from '../../domain/browserBookmark';
|
||||
import type { HostBrowserBookmark, HostBrowserCommand, HostBrowserDownload, HostBrowserState } from '../../domain/hostBrowser';
|
||||
import { hostBrowserClient, readHostBrowserIcon } from '../../infrastructure/services/hostBrowserService';
|
||||
|
||||
export function useHostBrowserControls(browserId: string, ready: boolean) {
|
||||
const [state, setState] = useState<HostBrowserState | null>(null);
|
||||
const [bookmarks, setBookmarks] = useState<HostBrowserBookmark[]>([]);
|
||||
const [downloads, setDownloads] = useState<HostBrowserDownload[]>([]);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [focusAddress, setFocusAddress] = useState(0);
|
||||
const [downloadStarted, setDownloadStarted] = useState(0);
|
||||
const generation = useRef(0);
|
||||
const client = useRef<ReturnType<typeof hostBrowserClient> | null>(null);
|
||||
const report = useCallback((reason: unknown) => setError(reason instanceof Error ? reason.message : String(reason)), []);
|
||||
|
||||
const command = useCallback(async (input: HostBrowserCommand) => {
|
||||
const current = client.current;
|
||||
const revision = generation.current;
|
||||
if (!current) return false;
|
||||
setError(null);
|
||||
try {
|
||||
const next = await current.command(input);
|
||||
if (revision !== generation.current) return false;
|
||||
setState(next);
|
||||
if (input.type === 'new-tab' || (input.type === 'close-tab' && !next.tabs.find((tab) => tab.id === next.activeTabId)?.url)) setFocusAddress((value) => value + 1);
|
||||
return true;
|
||||
} catch (reason) { if (revision === generation.current) report(reason); return false; }
|
||||
}, [report]);
|
||||
|
||||
useEffect(() => {
|
||||
const revision = ++generation.current;
|
||||
setState(null); setBookmarks([]); setDownloads([]); setError(null); setDownloadStarted(0);
|
||||
if (!ready) return;
|
||||
let current: ReturnType<typeof hostBrowserClient>;
|
||||
try { current = hostBrowserClient(browserId); } catch (reason) { report(reason); return; }
|
||||
client.current = current;
|
||||
let stateRevision = 0;
|
||||
let bookmarkRevision = 0;
|
||||
let downloadRevision = 0;
|
||||
const active = () => revision === generation.current;
|
||||
const refreshBookmarks = async () => {
|
||||
const request = ++bookmarkRevision;
|
||||
try { const items = await current.bookmarks(); if (active() && request === bookmarkRevision) setBookmarks(items); }
|
||||
catch (reason) { if (active()) report(reason); }
|
||||
};
|
||||
const off = current.subscribe((event) => {
|
||||
if (!active()) return;
|
||||
if (event.type === 'state') { stateRevision++; setState(event.state); }
|
||||
if (event.type === 'bookmarks') void refreshBookmarks();
|
||||
if (event.type === 'downloads') {
|
||||
downloadRevision++; setDownloads(event.downloads);
|
||||
if (event.startedId) setDownloadStarted((value) => value + 1);
|
||||
}
|
||||
if (event.type === 'shortcut') {
|
||||
if (event.shortcut.type === 'focus-address') setFocusAddress((value) => value + 1);
|
||||
else void command(event.shortcut.type === 'new-tab' ? { type: 'new-tab' } : { type: event.shortcut.type, tabId: event.shortcut.tabId });
|
||||
}
|
||||
});
|
||||
const requestState = stateRevision;
|
||||
void current.state().then((next) => { if (active() && stateRevision === requestState) setState(next); }).catch((reason) => { if (active()) report(reason); });
|
||||
void refreshBookmarks();
|
||||
const requestDownloads = downloadRevision;
|
||||
void current.downloads().then((items) => { if (active() && requestDownloads === downloadRevision) setDownloads(items); }).catch((reason) => { if (active()) report(reason); });
|
||||
return () => { generation.current = revision + 1; client.current = null; off(); };
|
||||
}, [browserId, ready, command, report]);
|
||||
|
||||
const mutateBookmark = useCallback(async (input: Partial<BrowserBookmark> | string) => {
|
||||
const current = client.current;
|
||||
const revision = generation.current;
|
||||
if (!current) return false;
|
||||
try {
|
||||
if (typeof input === 'string') await current.removeBookmark(input);
|
||||
else await current.saveBookmark(input);
|
||||
const items = await current.bookmarks();
|
||||
if (revision === generation.current) { setBookmarks(items); setError(null); }
|
||||
return true;
|
||||
} catch (reason) { if (revision === generation.current) report(reason); return false; }
|
||||
}, [report]);
|
||||
|
||||
const downloadCommand = useCallback(async (type: Parameters<ReturnType<typeof hostBrowserClient>['downloadCommand']>[0]['type'], id?: string) => {
|
||||
const current = client.current;
|
||||
const revision = generation.current;
|
||||
if (!current) return;
|
||||
try {
|
||||
await current.downloadCommand({ type, id });
|
||||
const items = await current.downloads();
|
||||
if (revision === generation.current) setDownloads(items);
|
||||
} catch (reason) { if (revision === generation.current) report(reason); }
|
||||
}, [report]);
|
||||
|
||||
const readIcon = useCallback((file: File) => readHostBrowserIcon(file), []);
|
||||
return { state, bookmarks, downloads, error, command, mutateBookmark, downloadCommand, focusAddress, downloadStarted,
|
||||
requestAddressFocus: () => setFocusAddress((value) => value + 1), readIcon, dismissError: () => setError(null) };
|
||||
}
|
||||
@@ -0,0 +1,248 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import React from 'react';
|
||||
import { act, create } from 'react-test-renderer';
|
||||
import { installDomEnvironment } from '../../components/test-support/renderReactDom';
|
||||
import { netcattyBridge } from '../../infrastructure/services/netcattyBridge';
|
||||
import type { TerminalSettings } from '../../domain/models';
|
||||
import { useHostBrowserPanel } from './useHostBrowserPanel';
|
||||
|
||||
type Layout = Parameters<NonNullable<NetcattyBridge['setHostBrowserPanelLayout']>>[0];
|
||||
type Result = Awaited<ReturnType<NonNullable<NetcattyBridge['setHostBrowserPanelLayout']>>>;
|
||||
const layers = [{ image: 'data:image/png;base64,fixture', bounds: { x: 0, y: 0, width: 500, height: 600 } }];
|
||||
|
||||
async function fixture(t: test.TestContext) {
|
||||
const env = installDomEnvironment();
|
||||
const frames = new Map<number, FrameRequestCallback>();
|
||||
let frameId = 0;
|
||||
const originals = new Map(['requestAnimationFrame', 'cancelAnimationFrame', 'getComputedStyle']
|
||||
.map((name) => [name, Object.getOwnPropertyDescriptor(globalThis, name)]));
|
||||
Object.defineProperty(globalThis, 'requestAnimationFrame', { configurable: true,
|
||||
value: (callback: FrameRequestCallback) => { frames.set(++frameId, callback); return frameId; } });
|
||||
Object.defineProperty(globalThis, 'cancelAnimationFrame', { configurable: true,
|
||||
value: (id: number) => frames.delete(id) });
|
||||
const resizeObservers = new Map<ResizeObserver, Set<Element>>();
|
||||
const resizeCallbacks = new Map<ResizeObserver, ResizeObserverCallback>();
|
||||
class LayoutResizeObserver implements ResizeObserver {
|
||||
constructor(callback: ResizeObserverCallback) { resizeCallbacks.set(this, callback); resizeObservers.set(this, new Set()); }
|
||||
observe(target: Element) { resizeObservers.get(this)?.add(target); }
|
||||
unobserve(target: Element) { resizeObservers.get(this)?.delete(target); }
|
||||
disconnect() { resizeObservers.delete(this); resizeCallbacks.delete(this); }
|
||||
}
|
||||
Object.defineProperty(env.window, 'ResizeObserver', { configurable: true, value: LayoutResizeObserver });
|
||||
const fonts = Object.assign(new env.window.EventTarget(), { status: 'loaded' });
|
||||
Object.defineProperty(env.document, 'fonts', { configurable: true, value: fonts });
|
||||
const style = env.document.createElement('div').style;
|
||||
style.colorScheme = 'dark';
|
||||
globalThis.getComputedStyle = () => style;
|
||||
const layouts: Layout[] = [];
|
||||
let respond: (layout: Layout) => Promise<Result> = async (layout) => layout.occluded ? { layers } : true;
|
||||
t.mock.method(netcattyBridge, 'get', () => ({
|
||||
setHostBrowserPanelLayout: async (layout: Layout) => { layouts.push(layout); return respond(layout); },
|
||||
openHostBrowser: async () => {},
|
||||
startPortForward: async () => assert.fail('a local browser needs no SSH transport'),
|
||||
closeHostBrowserPanel: async () => {},
|
||||
}));
|
||||
let panel: ReturnType<typeof useHostBrowserPanel>;
|
||||
function Panel({ visible = true, browserId = 'local:overlay-test' }) {
|
||||
panel = useHostBrowserPanel({ browserId,
|
||||
host: { id: 'local', protocol: 'local', label: 'Local terminal', hostname: '', username: '', tags: [], os: 'macos' },
|
||||
hosts: [], keys: [], identities: [], knownHosts: [], terminalSettings: {} as TerminalSettings,
|
||||
isVisible: visible });
|
||||
return React.createElement('div', { ref: panel.elementRef });
|
||||
}
|
||||
const element = env.document.createElement('div');
|
||||
env.document.body.append(element);
|
||||
let bounds = new env.window.DOMRect(200, 100, 500, 600);
|
||||
let reads = 0;
|
||||
element.getBoundingClientRect = () => { reads += 1; return bounds; };
|
||||
let renderer: ReturnType<typeof create>;
|
||||
t.after(async () => {
|
||||
if (renderer) await act(async () => renderer.unmount());
|
||||
for (const [name, previous] of originals) {
|
||||
if (previous) Object.defineProperty(globalThis, name, previous);
|
||||
else Reflect.deleteProperty(globalThis, name);
|
||||
}
|
||||
env.cleanup();
|
||||
});
|
||||
await act(async () => { renderer = create(React.createElement(Panel), { createNodeMock: () => element }); });
|
||||
const overlay = (html: string, x = 220, y = 110) => {
|
||||
env.document.body.insertAdjacentHTML('beforeend', html);
|
||||
const node = env.document.body.lastElementChild!;
|
||||
for (const target of [node, ...node.children]) {
|
||||
target.getBoundingClientRect = () => new env.window.DOMRect(x, y, 150, 40);
|
||||
}
|
||||
return node;
|
||||
};
|
||||
const tick = async () => {
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
const pending = [...frames.values()]; frames.clear();
|
||||
for (const callback of pending) callback(0);
|
||||
});
|
||||
};
|
||||
return { layouts, overlay, tick, panel: () => panel, pendingFrames: () => frames.size,
|
||||
env, element, style, fonts, reads: () => reads, setBounds: (next: DOMRect) => { bounds = next; },
|
||||
observerCount: () => resizeObservers.size,
|
||||
resize: () => { for (const [observer, callback] of resizeCallbacks) callback([], observer); },
|
||||
respond: (callback: typeof respond) => { respond = callback; },
|
||||
hide: async () => { await act(async () => renderer.update(React.createElement(Panel, { visible: false }))); } };
|
||||
}
|
||||
|
||||
const tooltip = '<div data-radix-popper-content-wrapper><div data-state="delayed-open"><span role="tooltip">Split vertically</span></div></div>';
|
||||
|
||||
test('a split-button tooltip keeps the browser picture, then restores its live view', async (t) => {
|
||||
const f = await fixture(t);
|
||||
assert.equal(f.layouts.at(-1)?.visible, true);
|
||||
const hint = f.overlay(tooltip);
|
||||
await f.tick();
|
||||
assert.equal(f.layouts.at(-1)?.visible, true, 'the owning panel remains open');
|
||||
assert.equal(f.layouts.at(-1)?.occluded, true);
|
||||
assert.deepEqual(f.panel().snapshot, layers, 'the native page is replaced by its picture, not a blank pane');
|
||||
const count = f.layouts.length;
|
||||
await f.tick();
|
||||
assert.equal(f.layouts.length, count, 'a stationary tooltip captures only once');
|
||||
hint.remove(); await f.tick();
|
||||
assert.equal(f.layouts.at(-1)?.occluded, false);
|
||||
assert.deepEqual(f.panel().snapshot, []);
|
||||
});
|
||||
|
||||
test('unrelated and closed poppers leave the browser live', async (t) => {
|
||||
const f = await fixture(t);
|
||||
f.overlay(tooltip, 10, 10);
|
||||
f.overlay('<div data-radix-popper-content-wrapper><div role="menu" data-state="closed"></div></div>');
|
||||
await f.tick();
|
||||
assert.equal(f.layouts.length, 1);
|
||||
assert.equal(f.layouts.at(-1)?.occluded, false);
|
||||
});
|
||||
|
||||
test('overlapping nonmodal menus yield with a picture; modal dialogs yield even beside the pane', async (t) => {
|
||||
const f = await fixture(t);
|
||||
const menu = f.overlay('<div data-radix-popper-content-wrapper><div role="dialog" data-state="open"></div></div>');
|
||||
await f.tick(); assert.equal(f.layouts.at(-1)?.occluded, true);
|
||||
menu.remove(); await f.tick();
|
||||
f.overlay('<div role="dialog" aria-modal="true"></div>', 0, 0);
|
||||
await f.tick(); assert.equal(f.layouts.at(-1)?.occluded, true);
|
||||
});
|
||||
|
||||
test('closing a menu while capture is pending cannot restore stale pictures', async (t) => {
|
||||
const f = await fixture(t);
|
||||
let complete: (result: Result) => void = () => assert.fail('capture was not started');
|
||||
f.respond((layout) => layout.occluded ? new Promise((resolve) => { complete = resolve; }) : Promise.resolve(true));
|
||||
const menu = f.overlay(tooltip); await f.tick();
|
||||
menu.remove(); await f.tick();
|
||||
await act(async () => complete({ layers }));
|
||||
assert.deepEqual(f.panel().snapshot, []);
|
||||
});
|
||||
|
||||
test('hiding or locking the pane clears pictures without taking another capture', async (t) => {
|
||||
const f = await fixture(t);
|
||||
f.overlay(tooltip); await f.tick();
|
||||
assert.deepEqual(f.panel().snapshot, layers);
|
||||
f.overlay('<div data-app-lock-overlay role="dialog"></div>'); await f.tick();
|
||||
assert.equal(f.layouts.at(-1)?.visible, false);
|
||||
assert.equal(f.layouts.at(-1)?.occluded, false);
|
||||
assert.deepEqual(f.panel().snapshot, []);
|
||||
await f.hide();
|
||||
assert.deepEqual(f.panel().snapshot, []);
|
||||
});
|
||||
|
||||
|
||||
test('a stationary visible browser leaves no repeating layout work', async (t) => {
|
||||
const f = await fixture(t);
|
||||
for (let index = 0; index < 600; index += 1) await f.tick();
|
||||
assert.equal(f.layouts.length, 1);
|
||||
assert.equal(f.pendingFrames(), 0, 'idle browser must stop scheduling document/style/geometry scans');
|
||||
});
|
||||
|
||||
|
||||
test('layout, scroll, font, stylesheet and theme changes coalesce without resuming idle scans', async (t) => {
|
||||
const f = await fixture(t);
|
||||
const reads = f.reads();
|
||||
f.setBounds(new f.env.window.DOMRect(100, 50, 400, 500));
|
||||
for (let index = 0; index < 20; index += 1) {
|
||||
f.resize();
|
||||
f.env.window.dispatchEvent(new f.env.window.Event('resize'));
|
||||
f.element.dispatchEvent(new f.env.window.Event('scroll', { bubbles: true }));
|
||||
}
|
||||
f.fonts.dispatchEvent(new f.env.window.Event('loadingdone'));
|
||||
f.env.document.dispatchEvent(new f.env.window.Event('load'));
|
||||
f.style.colorScheme = 'light';
|
||||
f.env.document.documentElement.classList.add('light');
|
||||
assert.equal(f.reads(), reads, 'bursts do not synchronously force repeated layout reads');
|
||||
await f.tick();
|
||||
assert.equal(f.reads(), reads + 1);
|
||||
assert.deepEqual(f.layouts.at(-1)?.bounds, { x: 100, y: 50, width: 400, height: 500 });
|
||||
assert.equal(f.layouts.at(-1)?.theme, 'light');
|
||||
assert.equal(f.pendingFrames(), 0);
|
||||
f.env.document.body.setAttribute('inert', ''); await f.tick();
|
||||
assert.equal(f.layouts.at(-1)?.visible, false);
|
||||
f.env.document.body.removeAttribute('inert'); await f.tick();
|
||||
assert.equal(f.layouts.at(-1)?.visible, true);
|
||||
});
|
||||
|
||||
test('resize observation follows a moving overlay and is released while hidden', async (t) => {
|
||||
const f = await fixture(t);
|
||||
const popper = f.overlay(tooltip, 10, 10); await f.tick();
|
||||
assert.equal(f.layouts.at(-1)?.occluded, false);
|
||||
popper.firstElementChild!.getBoundingClientRect = () => new f.env.window.DOMRect(220, 110, 150, 40);
|
||||
f.resize(); await f.tick();
|
||||
assert.equal(f.layouts.at(-1)?.occluded, true);
|
||||
await f.hide();
|
||||
assert.equal(f.observerCount(), 0);
|
||||
const reads = f.reads();
|
||||
f.overlay('<div role="dialog" aria-modal="true"></div>');
|
||||
f.env.window.dispatchEvent(new f.env.window.Event('resize'));
|
||||
f.fonts.dispatchEvent(new f.env.window.Event('loadingdone'));
|
||||
await f.tick();
|
||||
assert.equal(f.pendingFrames(), 0);
|
||||
assert.equal(f.reads(), reads);
|
||||
});
|
||||
|
||||
test('only pane/ancestor and overlay motion follows frames, then stops at settlement', async (t) => {
|
||||
const f = await fixture(t);
|
||||
const animation = { pending: false, playState: 'running' };
|
||||
f.element.getAnimations = () => [animation as unknown as Animation];
|
||||
f.element.dispatchEvent(new f.env.window.Event('transitionrun', { bubbles: true }));
|
||||
const reads = f.reads();
|
||||
for (let index = 0; index < 5; index += 1) {
|
||||
f.setBounds(new f.env.window.DOMRect(200 + index * 10, 100, 500, 600));
|
||||
await f.tick();
|
||||
}
|
||||
assert.equal(f.reads(), reads + 5);
|
||||
assert.equal(f.layouts.at(-1)?.bounds?.x, 240);
|
||||
animation.playState = 'finished'; await f.tick();
|
||||
assert.equal(f.pendingFrames(), 0);
|
||||
|
||||
const spinner = f.env.document.createElement('div'); f.env.document.body.append(spinner); await f.tick();
|
||||
spinner.getAnimations = () => [animation as unknown as Animation];
|
||||
const settledReads = f.reads();
|
||||
spinner.dispatchEvent(new f.env.window.Event('animationstart', { bubbles: true }));
|
||||
await f.tick();
|
||||
assert.equal(f.reads(), settledReads, 'unrelated cursor/spinner animation does not wake the browser');
|
||||
});
|
||||
|
||||
test('removing an animating overlay cannot leave an endless layout loop', async (t) => {
|
||||
const f = await fixture(t);
|
||||
const popper = f.overlay(tooltip);
|
||||
popper.firstElementChild!.getAnimations = () => [{ pending: false, playState: 'running' } as Animation];
|
||||
await f.tick();
|
||||
assert.equal(f.pendingFrames(), 1);
|
||||
popper.remove(); await f.tick();
|
||||
assert.equal(f.pendingFrames(), 0);
|
||||
assert.equal(f.layouts.at(-1)?.occluded, false);
|
||||
});
|
||||
|
||||
test('an old capture stays obsolete when an identical overlay is opened again', async (t) => {
|
||||
const f = await fixture(t);
|
||||
const captures: ((result: Result) => void)[] = [];
|
||||
f.respond((layout) => layout.occluded ? new Promise((resolve) => { captures.push(resolve); }) : Promise.resolve(true));
|
||||
const first = f.overlay(tooltip); await f.tick();
|
||||
first.remove(); await f.tick();
|
||||
f.overlay(tooltip); await f.tick();
|
||||
assert.equal(captures.length, 2);
|
||||
await act(async () => captures[0]({ layers }));
|
||||
assert.deepEqual(f.panel().snapshot, [], 'the first A capture must not become valid after A -> B -> A');
|
||||
await act(async () => captures[1]({ layers }));
|
||||
assert.deepEqual(f.panel().snapshot, layers);
|
||||
});
|
||||
@@ -2,6 +2,16 @@ import { useEffect, useLayoutEffect, useRef, useState } from 'react';
|
||||
import { currentBrowserThemeTokens, mountHostBrowser } from '../hostBrowser';
|
||||
import type { Host, Identity, KnownHost, SSHKey, TerminalSettings } from '../../domain/models';
|
||||
import { netcattyBridge } from '../../infrastructure/services/netcattyBridge';
|
||||
import type { HostBrowserPanelSnapshotLayer } from '../../types/global/netcatty-bridge-sync';
|
||||
|
||||
function overlapsBrowser(rect: DOMRect, element: Element): boolean {
|
||||
const style = getComputedStyle(element);
|
||||
if (style.display === 'none' || style.visibility === 'hidden' || element.closest('[hidden], [inert], [data-state="closed"]')) return false;
|
||||
const overlay = element.getBoundingClientRect();
|
||||
return overlay.width > 0 && overlay.height > 0
|
||||
&& overlay.x < rect.right && overlay.right > rect.x
|
||||
&& overlay.y < rect.bottom && overlay.bottom > rect.y;
|
||||
}
|
||||
|
||||
interface BrowserPanelOptions {
|
||||
browserId: string;
|
||||
@@ -22,33 +32,126 @@ export function useHostBrowserPanel(options: BrowserPanelOptions) {
|
||||
const mountedBrowserId = useRef<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [attempt, setAttempt] = useState(0);
|
||||
const [readyId, setReadyId] = useState<string | null>(null);
|
||||
const [snapshot, setSnapshot] = useState<{ browserId: string; layers: HostBrowserPanelSnapshotLayer[] } | null>(null);
|
||||
const { browserId, isVisible } = options;
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const bridge = netcattyBridge.get();
|
||||
let frame = 0;
|
||||
let frame: number | null = null;
|
||||
let previous = '';
|
||||
let disposed = false;
|
||||
let layoutRevision = 0;
|
||||
const element = elementRef.current;
|
||||
const doc = element?.ownerDocument;
|
||||
const view = doc?.defaultView;
|
||||
const observed = new Set<Element>();
|
||||
const animations = new Map<Animation, Element>();
|
||||
const schedule = () => {
|
||||
if (disposed || frame !== null) return;
|
||||
frame = requestAnimationFrame(() => {
|
||||
frame = null;
|
||||
for (const [animation, target] of animations) {
|
||||
if (!target.isConnected || (!animation.pending && animation.playState !== 'running')) animations.delete(animation);
|
||||
}
|
||||
update();
|
||||
if (animations.size > 0) schedule();
|
||||
});
|
||||
};
|
||||
const trackAnimations = (target: Element) => {
|
||||
for (const animation of target.getAnimations?.() ?? []) {
|
||||
if (!animation.pending && animation.playState !== 'running') continue;
|
||||
animations.set(animation, target);
|
||||
}
|
||||
};
|
||||
const resizeObserver = isVisible && view?.ResizeObserver ? new view.ResizeObserver(schedule) : null;
|
||||
const update = () => {
|
||||
const element = elementRef.current;
|
||||
const rect = element?.getBoundingClientRect();
|
||||
// Native page views must leave the screen with their owning pane and
|
||||
// must not cover a renderer dialog or a toolbar's open menu.
|
||||
const covered = !!document.querySelector('[role="dialog"], [role="alertdialog"], [role="menu"][data-state="open"], [data-radix-popper-content-wrapper]');
|
||||
// Native views sit above renderer portals. Only overlapping poppers need
|
||||
// to yield; dialogs are modal even when their content is beside the pane.
|
||||
const locked = !!document.querySelector('[data-app-lock-overlay]');
|
||||
const modal = !!document.querySelector('[role="dialog"][aria-modal="true"], [role="alertdialog"]');
|
||||
const overlays = Array.from(document.querySelectorAll(
|
||||
'[role="dialog"], [role="alertdialog"], [role="menu"][data-state="open"], [data-radix-popper-content-wrapper] > *',
|
||||
));
|
||||
const covered = modal || (!!rect && overlays.some((overlay) => overlapsBrowser(rect, overlay)));
|
||||
const targets = new Set<Element>(overlays);
|
||||
for (let target: Element | null = element; target; target = target.parentElement) targets.add(target);
|
||||
for (const target of observed) {
|
||||
if (!targets.has(target)) { resizeObserver?.unobserve(target); observed.delete(target); }
|
||||
}
|
||||
for (const target of targets) {
|
||||
if (observed.has(target)) continue;
|
||||
observed.add(target);
|
||||
resizeObserver?.observe(target);
|
||||
trackAnimations(target);
|
||||
}
|
||||
const visible = isVisible && !!element?.isConnected && !!rect
|
||||
&& rect.width > 0 && rect.height > 0 && !element?.closest('[inert]') && !covered;
|
||||
&& rect.width > 0 && rect.height > 0 && !element?.closest('[inert]') && !locked;
|
||||
if (!visible) setSnapshot(null);
|
||||
const bounds = rect ? { x: rect.x, y: rect.y, width: rect.width, height: rect.height } : undefined;
|
||||
const theme = element && getComputedStyle(element).colorScheme === 'light' ? 'light' : 'dark';
|
||||
const themeTokens = currentBrowserThemeTokens(element ?? undefined);
|
||||
const next = JSON.stringify({ browserId, visible, bounds, theme, themeTokens });
|
||||
const layout: Parameters<NonNullable<NetcattyBridge['setHostBrowserPanelLayout']>>[0] = {
|
||||
browserId, visible, occluded: visible && covered, bounds, theme, themeTokens,
|
||||
};
|
||||
const next = JSON.stringify(layout);
|
||||
if (next !== previous) {
|
||||
previous = next;
|
||||
void bridge?.setHostBrowserPanelLayout?.({ browserId, visible, bounds, theme, themeTokens }).catch(() => {});
|
||||
const revision = ++layoutRevision;
|
||||
void bridge?.setHostBrowserPanelLayout?.(layout).then((result) => {
|
||||
if (disposed || layoutRevision !== revision) return;
|
||||
setSnapshot(visible && covered && typeof result === 'object' ? { browserId, layers: result.layers } : null);
|
||||
}).catch(() => {});
|
||||
}
|
||||
if (isVisible) frame = requestAnimationFrame(update);
|
||||
};
|
||||
if (!isVisible) {
|
||||
setSnapshot(null);
|
||||
void bridge?.setHostBrowserPanelLayout?.({ browserId, visible: false }).catch(() => {});
|
||||
return;
|
||||
}
|
||||
|
||||
// Layout, theme and portal changes coalesce into a single frame. Keep
|
||||
// following only running animations on the pane's ancestors or overlay
|
||||
// containers; unrelated spinner/cursor animations must not restart polling.
|
||||
const mutationObserver = view?.MutationObserver ? new view.MutationObserver(schedule) : null;
|
||||
if (doc) mutationObserver?.observe(doc.documentElement, {
|
||||
subtree: true, childList: true, attributes: true,
|
||||
attributeFilter: ['style', 'class', 'hidden', 'inert', 'role', 'aria-modal', 'data-state',
|
||||
'data-app-lock-overlay', 'data-radix-popper-content-wrapper', 'href', 'media', 'disabled'],
|
||||
});
|
||||
const onMotion = (event: Event) => {
|
||||
const target = event.target;
|
||||
if (!element || !view || !(target instanceof view.Element)) return;
|
||||
if (!target.contains(element) && !target.matches(
|
||||
'[role="dialog"], [role="alertdialog"], [role="menu"], [data-app-lock-overlay], '
|
||||
+ '[data-radix-popper-content-wrapper], [data-radix-popper-content-wrapper] > *',
|
||||
)) return;
|
||||
trackAnimations(target);
|
||||
schedule();
|
||||
};
|
||||
const motionEvents = ['transitionrun', 'transitionend', 'transitioncancel', 'animationstart', 'animationend', 'animationcancel'];
|
||||
for (const event of motionEvents) doc?.addEventListener(event, onMotion, true);
|
||||
doc?.addEventListener('scroll', schedule, true);
|
||||
doc?.addEventListener('load', schedule, true);
|
||||
view?.addEventListener('resize', schedule);
|
||||
doc?.fonts?.addEventListener('loadingdone', schedule);
|
||||
doc?.fonts?.addEventListener('loadingerror', schedule);
|
||||
if (doc?.fonts?.status === 'loading') void doc.fonts.ready.then(schedule);
|
||||
update();
|
||||
if (animations.size > 0) schedule();
|
||||
return () => {
|
||||
cancelAnimationFrame(frame);
|
||||
disposed = true;
|
||||
if (frame !== null) cancelAnimationFrame(frame);
|
||||
resizeObserver?.disconnect();
|
||||
mutationObserver?.disconnect();
|
||||
for (const event of motionEvents) doc?.removeEventListener(event, onMotion, true);
|
||||
doc?.removeEventListener('scroll', schedule, true);
|
||||
doc?.removeEventListener('load', schedule, true);
|
||||
view?.removeEventListener('resize', schedule);
|
||||
doc?.fonts?.removeEventListener('loadingdone', schedule);
|
||||
doc?.fonts?.removeEventListener('loadingerror', schedule);
|
||||
void bridge?.setHostBrowserPanelLayout?.({ browserId, visible: false }).catch(() => {});
|
||||
};
|
||||
}, [browserId, isVisible]);
|
||||
@@ -58,7 +161,9 @@ export function useHostBrowserPanel(options: BrowserPanelOptions) {
|
||||
opened.current = true;
|
||||
setError(null);
|
||||
let cancelled = false;
|
||||
void mountHostBrowser({ ...optionsRef.current, browserId }).catch((reason: unknown) => {
|
||||
void mountHostBrowser({ ...optionsRef.current, browserId }).then(() => {
|
||||
if (mountedBrowserId.current === browserId) setReadyId(browserId);
|
||||
}).catch((reason: unknown) => {
|
||||
opened.current = false;
|
||||
if (cancelled) return;
|
||||
setError(reason instanceof Error ? reason.message : String(reason));
|
||||
@@ -80,5 +185,6 @@ export function useHostBrowserPanel(options: BrowserPanelOptions) {
|
||||
};
|
||||
}, [browserId]);
|
||||
|
||||
return { elementRef, error, retry: () => setAttempt((value) => value + 1) };
|
||||
return { elementRef, error, ready: readyId === browserId, snapshot: isVisible && snapshot?.browserId === browserId ? snapshot.layers : [],
|
||||
retry: () => setAttempt((value) => value + 1) };
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
import { useEffect, useRef, useState, type PointerEvent } from "react";
|
||||
import {
|
||||
clampMonitorColumnWidth,
|
||||
normalizeMonitorColumnWidths,
|
||||
normalizeMonitorHiddenColumns,
|
||||
type MonitorColumn,
|
||||
} from "../../domain/monitorTableLayout";
|
||||
import { localStorageAdapter } from "../../infrastructure/persistence/localStorageAdapter";
|
||||
import {
|
||||
STORAGE_KEY_MONITOR_TABLE_COLUMNS,
|
||||
STORAGE_KEY_MONITOR_HIDDEN_COLUMNS,
|
||||
} from "../../infrastructure/config/storageKeys";
|
||||
|
||||
export function useMonitorTableColumns() {
|
||||
const [widths, setWidths] = useState(() =>
|
||||
normalizeMonitorColumnWidths(
|
||||
localStorageAdapter.read(STORAGE_KEY_MONITOR_TABLE_COLUMNS),
|
||||
),
|
||||
);
|
||||
const [hidden, setHidden] = useState(() =>
|
||||
normalizeMonitorHiddenColumns(
|
||||
localStorageAdapter.read(STORAGE_KEY_MONITOR_HIDDEN_COLUMNS),
|
||||
),
|
||||
);
|
||||
const currentHidden = useRef(hidden);
|
||||
const setVisible = (key: MonitorColumn, visible: boolean) => {
|
||||
const previous = currentHidden.current;
|
||||
const next = normalizeMonitorHiddenColumns(
|
||||
visible ? previous.filter((item) => item !== key) : [...previous, key],
|
||||
);
|
||||
currentHidden.current = next;
|
||||
setHidden(next);
|
||||
localStorageAdapter.write(STORAGE_KEY_MONITOR_HIDDEN_COLUMNS, next);
|
||||
};
|
||||
const current = useRef(widths);
|
||||
const cleanup = useRef<(() => void) | null>(null);
|
||||
useEffect(() => () => cleanup.current?.(), []);
|
||||
const resize = (key: MonitorColumn, width: number, persist = true) => {
|
||||
current.current = {
|
||||
...current.current,
|
||||
[key]: clampMonitorColumnWidth(key, width),
|
||||
};
|
||||
setWidths(current.current);
|
||||
if (persist)
|
||||
localStorageAdapter.write(
|
||||
STORAGE_KEY_MONITOR_TABLE_COLUMNS,
|
||||
current.current,
|
||||
);
|
||||
};
|
||||
const startResize = (
|
||||
key: MonitorColumn,
|
||||
event: PointerEvent<HTMLElement>,
|
||||
) => {
|
||||
if (event.button !== 0) return;
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
cleanup.current?.();
|
||||
const pointerId = event.pointerId;
|
||||
const handle = event.currentTarget;
|
||||
handle.setPointerCapture(pointerId);
|
||||
const start = event.clientX;
|
||||
const width = current.current[key];
|
||||
const cursor = document.body.style.cursor;
|
||||
const userSelect = document.body.style.userSelect;
|
||||
document.body.style.cursor = "col-resize";
|
||||
document.body.style.userSelect = "none";
|
||||
const move = (next: globalThis.PointerEvent) => {
|
||||
if (next.pointerId === pointerId)
|
||||
resize(key, width + next.clientX - start, false);
|
||||
};
|
||||
const finish = (next: globalThis.PointerEvent) => {
|
||||
if (next.pointerId !== pointerId) return;
|
||||
localStorageAdapter.write(
|
||||
STORAGE_KEY_MONITOR_TABLE_COLUMNS,
|
||||
current.current,
|
||||
);
|
||||
cleanup.current?.();
|
||||
};
|
||||
const blur = () => {
|
||||
localStorageAdapter.write(
|
||||
STORAGE_KEY_MONITOR_TABLE_COLUMNS,
|
||||
current.current,
|
||||
);
|
||||
cleanup.current?.();
|
||||
};
|
||||
cleanup.current = () => {
|
||||
window.removeEventListener("pointermove", move);
|
||||
window.removeEventListener("pointerup", finish);
|
||||
window.removeEventListener("pointercancel", finish);
|
||||
window.removeEventListener("blur", blur);
|
||||
if (handle.hasPointerCapture(pointerId))
|
||||
handle.releasePointerCapture(pointerId);
|
||||
document.body.style.cursor = cursor;
|
||||
document.body.style.userSelect = userSelect;
|
||||
cleanup.current = null;
|
||||
};
|
||||
window.addEventListener("pointermove", move);
|
||||
window.addEventListener("pointerup", finish);
|
||||
window.addEventListener("pointercancel", finish);
|
||||
window.addEventListener("blur", blur);
|
||||
};
|
||||
return { widths, resize, startResize, hidden, setVisible };
|
||||
}
|
||||
@@ -1,27 +0,0 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
|
||||
import {
|
||||
parseScriptsViewMode,
|
||||
SCRIPTS_VIEW_MODE_STORAGE_KEY,
|
||||
} from "./useScriptsViewMode.ts";
|
||||
|
||||
test("scripts view mode parser only accepts 'stacked'", () => {
|
||||
assert.equal(parseScriptsViewMode("stacked"), "stacked");
|
||||
assert.equal(parseScriptsViewMode("list"), "list");
|
||||
assert.equal(parseScriptsViewMode("grid"), "list");
|
||||
assert.equal(parseScriptsViewMode(null), "list");
|
||||
assert.equal(parseScriptsViewMode(""), "list");
|
||||
});
|
||||
|
||||
test("scripts view mode hook persists the chosen mode", () => {
|
||||
const source = readFileSync(new URL("./useScriptsViewMode.ts", import.meta.url), "utf8");
|
||||
assert.match(source, /useStoredString/);
|
||||
// The shared hook owns persistence and same-/cross-window subscriptions.
|
||||
assert.match(
|
||||
source,
|
||||
/useStoredString\(SCRIPTS_VIEW_MODE_STORAGE_KEY, "list", isScriptsViewMode\)/,
|
||||
);
|
||||
assert.equal(SCRIPTS_VIEW_MODE_STORAGE_KEY.startsWith("netcatty:"), true);
|
||||
});
|
||||
@@ -1,19 +0,0 @@
|
||||
import { STORAGE_KEY_SCRIPTS_SIDE_PANEL_VIEW } from "../../infrastructure/config/storageKeys";
|
||||
import { useStoredString } from "./useStoredString";
|
||||
|
||||
/** View modes for the scripts side panel library:
|
||||
* 'list' (one snippet per row, tree of packages) and 'stacked' (compact
|
||||
* wrap-around chips). */
|
||||
export type ScriptsViewMode = "list" | "stacked";
|
||||
|
||||
export const SCRIPTS_VIEW_MODE_STORAGE_KEY = STORAGE_KEY_SCRIPTS_SIDE_PANEL_VIEW;
|
||||
|
||||
/** Pure resolver so callers and tests share one validation rule. */
|
||||
export const parseScriptsViewMode = (value: string | null): ScriptsViewMode =>
|
||||
value === "stacked" ? "stacked" : "list";
|
||||
|
||||
const isScriptsViewMode = (value: string | null): value is ScriptsViewMode =>
|
||||
value === "list" || value === "stacked";
|
||||
|
||||
export const useScriptsViewMode = () =>
|
||||
useStoredString(SCRIPTS_VIEW_MODE_STORAGE_KEY, "list", isScriptsViewMode);
|
||||
@@ -1,59 +0,0 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import React from "react";
|
||||
import { act, create, type ReactTestRenderer } from "react-test-renderer";
|
||||
import { SCRIPTS_VIEW_MODE_STORAGE_KEY, useScriptsViewMode } from "./useScriptsViewMode.ts";
|
||||
|
||||
test("script view changes follow native storage events from another window", async (t) => {
|
||||
const windowA = new EventTarget();
|
||||
const windowB = new EventTarget();
|
||||
let activeWindow = windowA;
|
||||
const data = new Map<string, string>();
|
||||
const values = {
|
||||
localStorage: { getItem: (key: string) => data.get(key) ?? null, setItem: (key: string, value: string) => { data.set(key, value); } },
|
||||
addEventListener: (type: string, listener: EventListener) => activeWindow.addEventListener(type, listener),
|
||||
removeEventListener: (type: string, listener: EventListener) => activeWindow.removeEventListener(type, listener),
|
||||
IS_REACT_ACT_ENVIRONMENT: true,
|
||||
};
|
||||
const previous = new Map<string, PropertyDescriptor | undefined>();
|
||||
for (const [key, value] of Object.entries(values)) {
|
||||
previous.set(key, Object.getOwnPropertyDescriptor(globalThis, key));
|
||||
Object.defineProperty(globalThis, key, { configurable: true, value });
|
||||
}
|
||||
let a!: ReturnType<typeof useScriptsViewMode>;
|
||||
let b!: ReturnType<typeof useScriptsViewMode>;
|
||||
function FirstWindow() { a = useScriptsViewMode(); return null; }
|
||||
function SecondWindow() { b = useScriptsViewMode(); return null; }
|
||||
let first!: ReactTestRenderer;
|
||||
let second!: ReactTestRenderer;
|
||||
t.after(async () => {
|
||||
activeWindow = windowA; await act(async () => first?.unmount());
|
||||
activeWindow = windowB; await act(async () => second?.unmount());
|
||||
for (const [key, descriptor] of previous) {
|
||||
if (descriptor) Object.defineProperty(globalThis, key, descriptor);
|
||||
else Reflect.deleteProperty(globalThis, key);
|
||||
}
|
||||
});
|
||||
activeWindow = windowA; await act(async () => { first = create(React.createElement(FirstWindow)); });
|
||||
activeWindow = windowB; await act(async () => { second = create(React.createElement(SecondWindow)); });
|
||||
assert.equal(a[0], "list"); assert.equal(b[0], "list");
|
||||
// BrowserWindow A writes shared storage. Electron emits the native event
|
||||
// in B, not an adapter CustomEvent from A's independent JS environment.
|
||||
activeWindow = windowA; await act(async () => a[1]("stacked"));
|
||||
assert.equal(data.get(SCRIPTS_VIEW_MODE_STORAGE_KEY), "stacked");
|
||||
assert.equal(b[0], "list");
|
||||
const notify = async (target: EventTarget, key = SCRIPTS_VIEW_MODE_STORAGE_KEY) => {
|
||||
const event = new Event("storage");
|
||||
Object.defineProperty(event, "key", { value: key });
|
||||
await act(async () => { target.dispatchEvent(event); });
|
||||
};
|
||||
activeWindow = windowB; await notify(windowB);
|
||||
assert.equal(b[0], "stacked");
|
||||
await act(async () => b[1]("list"));
|
||||
assert.equal(a[0], "stacked");
|
||||
activeWindow = windowA; await notify(windowA);
|
||||
assert.equal(a[0], "list");
|
||||
data.set(SCRIPTS_VIEW_MODE_STORAGE_KEY, "stacked");
|
||||
await notify(windowA, "unrelated");
|
||||
assert.equal(a[0], "list", "unrelated preferences must not trigger an update");
|
||||
});
|
||||
@@ -0,0 +1,36 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
|
||||
test("FidoPromptModal ships PIN and touch UI wiring", () => {
|
||||
const source = readFileSync(join(import.meta.dirname, "FidoPromptModal.tsx"), "utf8");
|
||||
assert.match(source, /kind === "touch"/);
|
||||
assert.match(source, /fido-pin-input/);
|
||||
assert.match(source, /fido\.prompt\.pinTitle/);
|
||||
assert.match(source, /fido\.prompt\.touchTitle/);
|
||||
assert.match(source, /onSubmit\(request\.requestId/);
|
||||
assert.match(source, /onCancel\(request\.requestId/);
|
||||
});
|
||||
|
||||
test("AppView mounts FidoPromptModal", () => {
|
||||
const source = readFileSync(
|
||||
join(import.meta.dirname, "../application/app/AppView.tsx"),
|
||||
"utf8",
|
||||
);
|
||||
assert.match(source, /FidoPromptModal/);
|
||||
assert.match(source, /fidoPromptQueue/);
|
||||
assert.match(source, /handleFidoPromptSubmit/);
|
||||
});
|
||||
|
||||
test("GenerateStandardPanel exposes FIDO options", () => {
|
||||
const source = readFileSync(
|
||||
join(import.meta.dirname, "keychain/GenerateStandardPanel.tsx"),
|
||||
"utf8",
|
||||
);
|
||||
assert.match(source, /ED25519-SK/);
|
||||
assert.match(source, /ECDSA-SK/);
|
||||
assert.match(source, /resident/);
|
||||
assert.match(source, /verifyRequired/);
|
||||
assert.match(source, /fidoHint/);
|
||||
});
|
||||
@@ -0,0 +1,155 @@
|
||||
/** PIN entry and user-presence prompts from OpenSSH. */
|
||||
import { Clock3, Eye, EyeOff, Hand, KeyRound, Loader2, ShieldAlert, Usb } from "lucide-react";
|
||||
import React, { useCallback, useEffect, useId, useState } from "react";
|
||||
import { useI18n } from "../application/i18n/I18nProvider";
|
||||
import { Button } from "./ui/button";
|
||||
import { Dialog, DialogContent, DialogDescription, DialogTitle } from "./ui/dialog";
|
||||
import { Input } from "./ui/input";
|
||||
import { Label } from "./ui/label";
|
||||
|
||||
export type FidoPromptKind = "pin" | "touch" | "confirm" | "recovery";
|
||||
export interface FidoPromptRequest {
|
||||
requestId: string;
|
||||
kind: FidoPromptKind;
|
||||
message?: string;
|
||||
title?: string;
|
||||
keyName?: string;
|
||||
notificationOnly?: boolean;
|
||||
reason?: "device-unavailable" | "timeout" | "pin-failed" | "failed";
|
||||
allowPasswordFallback?: boolean;
|
||||
}
|
||||
interface FidoPromptModalProps {
|
||||
request: FidoPromptRequest | null;
|
||||
onSubmit: (requestId: string, response: string) => void;
|
||||
onCancel: (requestId: string) => void;
|
||||
}
|
||||
|
||||
export const FidoPromptModal: React.FC<FidoPromptModalProps> = ({ request, onSubmit, onCancel }) => {
|
||||
const { t } = useI18n();
|
||||
// Retain the content during Radix's closing animation, but clear the PIN.
|
||||
const [lastRequest, setLastRequest] = useState(request);
|
||||
const [pin, setPin] = useState("");
|
||||
const [showPin, setShowPin] = useState(false);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const descriptionId = useId();
|
||||
useEffect(() => { if (request) setLastRequest(request); }, [request]);
|
||||
useEffect(() => {
|
||||
setPin("");
|
||||
setShowPin(false);
|
||||
setIsSubmitting(false);
|
||||
}, [request?.requestId]);
|
||||
|
||||
const visibleRequest = request || lastRequest;
|
||||
const isTouch = visibleRequest?.kind === "touch" || visibleRequest?.kind === "confirm";
|
||||
const isRecovery = visibleRequest?.kind === "recovery";
|
||||
const recoveryReason = visibleRequest?.reason || "failed";
|
||||
const RecoveryIcon = recoveryReason === "device-unavailable" ? Usb : recoveryReason === "timeout" ? Clock3 : ShieldAlert;
|
||||
const handleSubmit = useCallback(() => {
|
||||
if (!request || request.notificationOnly || isSubmitting || (!isTouch && !isRecovery && !pin)) return;
|
||||
setIsSubmitting(true);
|
||||
onSubmit(request.requestId, isRecovery ? "retry" : isTouch ? "" : pin);
|
||||
}, [request, isSubmitting, isTouch, isRecovery, pin, onSubmit]);
|
||||
const handleCancel = useCallback(() => {
|
||||
if (request) onCancel(request.requestId);
|
||||
}, [request, onCancel]);
|
||||
|
||||
const message = visibleRequest?.message?.trim();
|
||||
const hasPinAndTouch = !isTouch && /user presence|touch|tap/i.test(message || "");
|
||||
const isErrorPrompt = /incorrect|invalid|wrong|failed|retries|attempts|错误|錯誤|剩余|剩餘/i.test(message || "");
|
||||
|
||||
return (
|
||||
<Dialog open={!!request} onOpenChange={open => !open && handleCancel()}>
|
||||
{visibleRequest && (
|
||||
<DialogContent
|
||||
hideCloseButton
|
||||
aria-describedby={descriptionId}
|
||||
overlayClassName="fido-prompt-overlay bg-black/20"
|
||||
className="fido-prompt-dialog z-[51] w-[calc(100%_-_2rem)] max-w-[400px] gap-0 overflow-hidden rounded-xl border-border/70 p-6 sm:rounded-xl"
|
||||
style={{ boxShadow: '0 20px 60px -20px rgb(0 0 0 / 0.28)' }}
|
||||
>
|
||||
<div key={visibleRequest.requestId} className="fido-prompt-stage">
|
||||
{isRecovery ? (
|
||||
<div className="pb-2 pt-2 text-center">
|
||||
<div className="mx-auto mb-5 flex h-16 w-16 items-center justify-center rounded-full bg-primary/8 text-primary" aria-hidden="true">
|
||||
<RecoveryIcon size={29} strokeWidth={1.5} />
|
||||
</div>
|
||||
<DialogTitle className="text-base">{t(`fido.recovery.${recoveryReason}.title`)}</DialogTitle>
|
||||
{visibleRequest.keyName && <p className="mt-2 truncate text-sm font-medium">{visibleRequest.keyName}</p>}
|
||||
<DialogDescription id={descriptionId} className="mt-2 text-sm leading-relaxed">
|
||||
{t(`fido.recovery.${recoveryReason}.desc`)}
|
||||
</DialogDescription>
|
||||
</div>
|
||||
) : isTouch ? (
|
||||
<div className="pb-5 pt-2 text-center">
|
||||
<div className="relative mx-auto mb-6 flex h-24 w-24 items-center justify-center" aria-hidden="true">
|
||||
<span className="fido-touch-ring absolute inset-0 rounded-full bg-primary/5" />
|
||||
<span className="fido-touch-ring fido-touch-ring-delayed absolute inset-0 rounded-full bg-primary/5" />
|
||||
<div className="fido-touch-symbol relative flex h-16 w-16 items-center justify-center rounded-full bg-primary/8 text-primary">
|
||||
<Hand size={30} strokeWidth={1.5} />
|
||||
</div>
|
||||
</div>
|
||||
<DialogTitle className="text-base">{visibleRequest.title || t("fido.prompt.touchTitle")}</DialogTitle>
|
||||
{visibleRequest.keyName && <p className="mt-2 truncate text-sm font-medium">{visibleRequest.keyName}</p>}
|
||||
<DialogDescription id={descriptionId} className="mt-2 text-sm leading-relaxed">
|
||||
{t("fido.prompt.touchDesc", { keyName: visibleRequest.keyName || "FIDO2" })}
|
||||
</DialogDescription>
|
||||
<div role="status" className="mt-5 flex items-center justify-center gap-2 text-xs text-muted-foreground">
|
||||
<span className="fido-status-dot h-1.5 w-1.5 rounded-full bg-primary" aria-hidden="true" />
|
||||
{t(visibleRequest.notificationOnly ? "fido.prompt.touchAutomatic" : "fido.prompt.touchWaiting")}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="mb-5 flex items-center gap-3">
|
||||
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-primary/8 text-primary" aria-hidden="true">
|
||||
<KeyRound size={21} strokeWidth={1.7} />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<DialogTitle className="text-base">{visibleRequest.title || t("fido.prompt.pinTitle")}</DialogTitle>
|
||||
{visibleRequest.keyName && <p className="mt-1 truncate text-xs font-medium">{visibleRequest.keyName}</p>}
|
||||
<DialogDescription id={descriptionId} className="mt-1 text-xs leading-relaxed">
|
||||
{t(hasPinAndTouch ? "fido.prompt.pinTouchDesc" : "fido.prompt.pinDesc", { keyName: visibleRequest.keyName || "FIDO2" })}
|
||||
</DialogDescription>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="fido-pin-input" className="text-xs">{t("fido.prompt.pinLabel")}</Label>
|
||||
<div className="relative">
|
||||
<Input id="fido-pin-input" type={showPin ? "text" : "password"}
|
||||
value={pin} onChange={e => setPin(e.target.value)} disabled={isSubmitting}
|
||||
onKeyDown={e => {
|
||||
if (e.key === "Enter") { e.preventDefault(); handleSubmit(); }
|
||||
}} autoFocus className="h-11 pr-11" autoComplete="off" spellCheck={false} />
|
||||
<Button type="button" variant="ghost" size="icon" disabled={isSubmitting}
|
||||
className="absolute right-1 top-1/2 h-8 w-8 -translate-y-1/2 text-muted-foreground"
|
||||
aria-label={t(showPin ? "fido.prompt.hidePin" : "fido.prompt.showPin")}
|
||||
aria-pressed={showPin} onClick={() => setShowPin(value => !value)}>
|
||||
{showPin ? <EyeOff size={15} /> : <Eye size={15} />}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{message && (isErrorPrompt ? (
|
||||
<p role="alert" className="mt-3 break-words text-xs leading-relaxed text-destructive">{message}</p>
|
||||
) : (
|
||||
<details className="mt-3 text-xs text-muted-foreground">
|
||||
<summary className="w-fit cursor-pointer rounded-sm text-[11px] outline-none transition-colors hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring">{t("fido.prompt.details")}</summary>
|
||||
<p className="mt-2 break-words leading-relaxed">{message}</p>
|
||||
</details>
|
||||
))}
|
||||
</div>
|
||||
<div className="-mx-6 -mb-6 mt-5 flex items-center justify-end gap-2 border-t border-border/50 bg-muted/15 px-6 py-3.5">
|
||||
<Button type="button" variant="ghost" className="h-9" onClick={handleCancel} disabled={isSubmitting}>{t(visibleRequest.notificationOnly ? "common.close" : "common.cancel")}</Button>
|
||||
{isRecovery && visibleRequest.allowPasswordFallback && <Button type="button" variant="outline" className="h-9" onClick={() => request && onSubmit(request.requestId, "fallback")}>{t("fido.recovery.usePassword")}</Button>}
|
||||
{!visibleRequest.notificationOnly && <Button type="button" className="h-9 min-w-20 gap-2" onClick={handleSubmit} disabled={isSubmitting || (!isTouch && !isRecovery && !pin)}>
|
||||
{isSubmitting && <Loader2 className="h-3.5 w-3.5 animate-spin" aria-hidden="true" />}
|
||||
{t(isSubmitting ? "fido.prompt.verifying" : isRecovery ? "fido.recovery.retry" : "common.continue")}
|
||||
</Button>}
|
||||
</div>
|
||||
</DialogContent>
|
||||
)}
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
export default FidoPromptModal;
|
||||
@@ -1,16 +1,137 @@
|
||||
import React from 'react';
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import { ArrowLeft, ArrowRight, Bookmark, Download, Edit2, Loader2, Plus, RotateCw, Square, Trash2, X } from 'lucide-react';
|
||||
import { useHostBrowserPanel } from '../application/state/useHostBrowserPanel';
|
||||
import { useHostBrowserControls } from '../application/state/useHostBrowserControls';
|
||||
import { useI18n } from '../application/i18n/I18nProvider';
|
||||
import type { BrowserBookmark } from '../domain/browserBookmark';
|
||||
import type { HostBrowserTab } from '../domain/hostBrowser';
|
||||
import { cn } from '../lib/utils';
|
||||
import { Button } from './ui/button';
|
||||
import { Input } from './ui/input';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from './ui/tooltip';
|
||||
import { Popover, PopoverContent, PopoverTrigger } from './ui/popover';
|
||||
import { ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuTrigger } from './ui/context-menu';
|
||||
import { Dialog, DialogContent, DialogTitle } from './ui/dialog';
|
||||
import { BrowserIcon } from './host-browser/BrowserIcon';
|
||||
import { BrowserBookmarkEditor } from './host-browser/BrowserBookmarkEditor';
|
||||
import { BrowserDownloads } from './host-browser/BrowserDownloads';
|
||||
import { BrowserIconEditor, type BrowserIconSelection } from './host-browser/BrowserIconEditor';
|
||||
|
||||
function ToolbarButton({ label, children, ...props }: React.ButtonHTMLAttributes<HTMLButtonElement> & { label: string }) {
|
||||
return <Tooltip><TooltipTrigger asChild><button type="button" aria-label={label} {...props} className={cn('grid h-7 w-7 shrink-0 place-items-center rounded-md text-muted-foreground hover:bg-secondary/70 hover:text-foreground disabled:pointer-events-none disabled:opacity-35', props.className)}>{children}</button></TooltipTrigger><TooltipContent side="top">{label}</TooltipContent></Tooltip>;
|
||||
}
|
||||
|
||||
export function HostBrowserSidePanel(props: Parameters<typeof useHostBrowserPanel>[0]) {
|
||||
const { elementRef, error, retry } = useHostBrowserPanel(props);
|
||||
const { elementRef, error: mountError, snapshot, retry, ready } = useHostBrowserPanel(props);
|
||||
const browser = useHostBrowserControls(props.browserId, ready && props.isVisible);
|
||||
const { t } = useI18n();
|
||||
return (
|
||||
<div ref={elementRef} className="relative h-full min-h-0 w-full overflow-hidden" data-section="host-browser-panel" data-browser-host-id={props.host.id}>
|
||||
{error && <div className="p-4 text-sm text-muted-foreground">
|
||||
<p>{error}</p>
|
||||
<button type="button" className="mt-3 text-primary" onClick={retry}>{t('common.retry')}</button>
|
||||
</div>}
|
||||
const active = browser.state?.tabs.find((tab) => tab.id === browser.state?.activeTabId);
|
||||
const connected = browser.state?.route === 'connected';
|
||||
const [address, setAddress] = useState('');
|
||||
const addressRef = useRef<HTMLInputElement>(null);
|
||||
const lastActive = useRef<string | undefined>(undefined);
|
||||
const [bookmarksOpen, setBookmarksOpen] = useState(false);
|
||||
const [downloadsOpen, setDownloadsOpen] = useState(false);
|
||||
const [editingBookmark, setEditingBookmark] = useState<Partial<BrowserBookmark> | null>(null);
|
||||
const [editingIcon, setEditingIcon] = useState<HostBrowserTab | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (lastActive.current !== active?.id || document.activeElement !== addressRef.current) setAddress(active?.url || '');
|
||||
lastActive.current = active?.id;
|
||||
}, [active?.id, active?.url]);
|
||||
useEffect(() => {
|
||||
if (!browser.focusAddress) return;
|
||||
addressRef.current?.focus(); addressRef.current?.select();
|
||||
}, [browser.focusAddress]);
|
||||
useEffect(() => { setEditingBookmark(null); setEditingIcon(null); setBookmarksOpen(false); setDownloadsOpen(false); }, [props.browserId]);
|
||||
useEffect(() => {
|
||||
if (browser.downloadStarted) { setDownloadsOpen(true); setBookmarksOpen(false); }
|
||||
}, [browser.downloadStarted]);
|
||||
|
||||
const newBookmark = () => {
|
||||
setBookmarksOpen(true);
|
||||
setEditingBookmark({ title: active?.url ? active.title || '' : '', url: active?.url || '', scope: 'host', color: '#247969', order: browser.bookmarks.length });
|
||||
};
|
||||
const navigate = (url: string) => { void browser.command({ type: 'navigate', url }); };
|
||||
const error = mountError || browser.error;
|
||||
return <div className="relative flex h-full min-h-0 min-w-0 w-full flex-col overflow-hidden bg-background text-foreground" data-section="host-browser-root" onKeyDownCapture={(event) => {
|
||||
if (!(event.metaKey || event.ctrlKey) || event.altKey) return;
|
||||
const key = event.key.toLowerCase();
|
||||
if (!['l', 't', 'w', 'r'].includes(key)) return;
|
||||
event.preventDefault(); event.stopPropagation();
|
||||
if (key === 'l') browser.requestAddressFocus();
|
||||
if (key === 't') void browser.command({ type: 'new-tab' });
|
||||
if (key === 'w') void browser.command({ type: 'close-tab' });
|
||||
if (key === 'r') void browser.command({ type: 'reload' });
|
||||
}}>
|
||||
<div className="shrink-0 border-b border-border/50" data-section="host-browser-controls">
|
||||
<div className="flex min-w-0 items-center gap-0.5 px-1.5 pt-1">
|
||||
<div role="tablist" aria-label={t('terminal.browser.title')} className="flex min-w-0 flex-1 items-center gap-0.5 overflow-x-auto">
|
||||
{browser.state?.tabs.map((tab) => <ContextMenu key={tab.id}><ContextMenuTrigger asChild><div role="tab" tabIndex={tab.id === active?.id ? 0 : -1} aria-selected={tab.id === active?.id} draggable
|
||||
className={cn('group flex h-7 min-w-[90px] max-w-[200px] flex-1 cursor-default items-center gap-1.5 rounded px-2 text-xs outline-none focus-visible:ring-1 focus-visible:ring-ring', tab.id === active?.id ? 'bg-secondary/65 text-foreground' : 'text-muted-foreground hover:bg-secondary/30')}
|
||||
onClick={() => void browser.command({ type: 'activate', tabId: tab.id })}
|
||||
onKeyDown={(event) => { if (event.key === 'Enter' || event.key === ' ') { event.preventDefault(); void browser.command({ type: 'activate', tabId: tab.id }); } }}
|
||||
onDragStart={(event) => { event.dataTransfer.setData('text/plain', tab.id); event.dataTransfer.effectAllowed = 'move'; }}
|
||||
onDragOver={(event) => { event.preventDefault(); event.dataTransfer.dropEffect = 'move'; }}
|
||||
onDrop={(event) => { event.preventDefault(); const id = event.dataTransfer.getData('text/plain'); if (browser.state?.tabs.some((item) => item.id === id)) void browser.command({ type: 'reorder', tabId: id, beforeTabId: tab.id }); }}>
|
||||
{tab.loading ? <Loader2 size={13} className="shrink-0 animate-spin" /> : <BrowserIcon image={tab.iconDataUrl} glyph={tab.iconGlyph} color={tab.iconColor} className="h-3.5 w-3.5" />}
|
||||
<span className="min-w-0 flex-1 truncate" title={tab.title || tab.url}>{tab.url ? tab.title || tab.url : t('terminal.browser.newTab')}</span>
|
||||
<button type="button" aria-label={t('terminal.browser.closeTab')} className="grid h-4 w-4 shrink-0 place-items-center rounded opacity-60 hover:bg-muted hover:opacity-100" onClick={(event) => { event.stopPropagation(); void browser.command({ type: 'close-tab', tabId: tab.id }); }}><X size={11} /></button>
|
||||
</div></ContextMenuTrigger><ContextMenuContent>
|
||||
<ContextMenuItem disabled={!tab.url} onClick={() => setEditingIcon(tab)}>{t('terminal.browser.changeIcon')}</ContextMenuItem>
|
||||
<ContextMenuItem disabled={!tab.url} onClick={() => void browser.command({ type: 'reset-icon', tabId: tab.id })}>{t('terminal.browser.resetIcon')}</ContextMenuItem>
|
||||
</ContextMenuContent></ContextMenu>)}
|
||||
</div>
|
||||
<ToolbarButton label={t('terminal.browser.newTab')} disabled={!ready} onClick={() => void browser.command({ type: 'new-tab' })}><Plus size={14} /></ToolbarButton>
|
||||
</div>
|
||||
<div className="flex min-w-0 items-center gap-0.5 px-1.5 py-1">
|
||||
<ToolbarButton label={t('terminal.browser.back')} disabled={!connected || !active?.canGoBack} onClick={() => void browser.command({ type: 'back' })}><ArrowLeft size={14} /></ToolbarButton>
|
||||
<ToolbarButton label={t('terminal.browser.forward')} disabled={!connected || !active?.canGoForward} onClick={() => void browser.command({ type: 'forward' })}><ArrowRight size={14} /></ToolbarButton>
|
||||
<ToolbarButton label={t(active?.loading ? 'terminal.browser.stop' : 'terminal.browser.reload')} disabled={!connected || !active?.url} onClick={() => void browser.command({ type: active?.loading ? 'stop' : 'reload' })}>{active?.loading ? <Square size={12} /> : <RotateCw size={14} />}</ToolbarButton>
|
||||
<form className="mx-1 min-w-0 flex-1" onSubmit={(event) => { event.preventDefault(); navigate(address); addressRef.current?.blur(); }}><Input ref={addressRef} aria-label={t('terminal.browser.address')} placeholder={t('terminal.browser.address')} value={address} onChange={(event) => setAddress(event.target.value)} autoComplete="off" spellCheck={false} className="h-7 min-w-0 rounded-md border-border/50 bg-secondary/35 px-2 text-xs" /></form>
|
||||
<ToolbarButton label={t('terminal.browser.bookmarks')} aria-pressed={bookmarksOpen} className={bookmarksOpen ? 'bg-secondary/70 text-foreground' : ''} onClick={() => { setBookmarksOpen((value) => !value); setDownloadsOpen(false); }}><Bookmark size={14} /></ToolbarButton>
|
||||
<Popover open={downloadsOpen} onOpenChange={setDownloadsOpen}><Tooltip><TooltipTrigger asChild><PopoverTrigger asChild><button type="button" aria-label={t('terminal.browser.downloads')} className="relative grid h-7 w-7 shrink-0 place-items-center rounded-md text-muted-foreground hover:bg-secondary/70 hover:text-foreground"><Download size={14} />{browser.downloads.some((item) => ['waiting', 'progressing'].includes(item.status)) && <span className="absolute right-1 top-1 h-1.5 w-1.5 rounded-full bg-primary" />}</button></PopoverTrigger></TooltipTrigger><TooltipContent side="top">{t('terminal.browser.downloads')}</TooltipContent></Tooltip><PopoverContent align="end" className="w-[min(380px,calc(100vw-24px))] p-0"><BrowserDownloads items={browser.downloads} command={browser.downloadCommand} /></PopoverContent></Popover>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
{error && <div role="status" className="flex shrink-0 items-center gap-2 border-b border-border/50 px-3 py-2 text-xs text-muted-foreground"><span className="min-w-0 flex-1">{error}</span>{mountError ? <Button size="sm" variant="ghost" onClick={retry}>{t('terminal.browser.retry')}</Button> : <button type="button" aria-label={t('common.close')} onClick={browser.dismissError}><X size={13} /></button>}</div>}
|
||||
<div className="flex min-h-0 flex-1 overflow-hidden">
|
||||
<div ref={elementRef} className="relative min-h-0 min-w-0 flex-1 overflow-hidden" data-section="host-browser-panel" data-browser-host-id={props.host.id}>
|
||||
{snapshot.map((layer, index) => <img key={index} src={layer.image} alt="" aria-hidden="true" draggable={false} className="absolute pointer-events-none select-none" style={{ left: layer.bounds.x, top: layer.bounds.y, width: layer.bounds.width, height: layer.bounds.height }} />)}
|
||||
{!connected ? <div className="flex h-full flex-col items-center justify-center gap-3 p-6 text-center text-xs text-muted-foreground"><p>{t(browser.state?.route === 'disconnected' ? 'terminal.browser.disconnected' : 'terminal.browser.connecting')}</p>{browser.state?.error && <p>{browser.state.error}</p>}{browser.state?.route === 'disconnected' && <Button size="sm" variant="secondary" onClick={() => void browser.command({ type: 'retry' })}>{t('terminal.browser.retry')}</Button>}</div>
|
||||
: !active?.url && <div className="h-full overflow-y-auto p-4"><div className="mb-3 flex items-center justify-between gap-2 text-xs text-muted-foreground"><span>{t('terminal.browser.bookmarks')}</span><Button size="sm" variant="ghost" className="h-7 text-xs" onClick={newBookmark}><Plus size={12} className="mr-1" />{t('terminal.browser.addBookmark')}</Button></div>{!browser.bookmarks.length ? <p className="py-8 text-center text-xs text-muted-foreground">{t('terminal.browser.empty')}</p> : <div className="grid grid-cols-[repeat(auto-fill,minmax(110px,1fr))] gap-1">{browser.bookmarks.map((item) => <button type="button" key={item.id} title={item.url} className="flex min-w-0 flex-col items-center gap-2 rounded-lg p-3 hover:bg-secondary/50" onClick={() => navigate(item.url)}><BrowserIcon className="h-9 w-9 rounded-lg bg-secondary/60 p-1.5" image={item.iconMode === 'custom' ? item.iconDataUrl : item.siteIconDataUrl} glyph={item.iconMode === 'custom' ? item.icon : item.siteIconGlyph || item.icon} color={item.color} /><span className="w-full truncate text-xs">{item.title}</span></button>)}</div>}</div>}
|
||||
</div>
|
||||
{bookmarksOpen && <aside className="flex w-[380px] max-w-full shrink-0 flex-col border-l border-border/50 bg-background" aria-label={t('terminal.browser.bookmarks')}>
|
||||
<div className="flex min-h-12 shrink-0 items-center justify-between gap-2 border-b border-border/60 px-4 py-3"><span className="truncate text-sm font-semibold">{t(editingBookmark ? editingBookmark.id ? 'terminal.browser.editBookmark' : 'terminal.browser.addBookmark' : 'terminal.browser.bookmarks')}</span><div className="flex items-center gap-1">{!editingBookmark && <ToolbarButton label={t('terminal.browser.addBookmark')} onClick={newBookmark}><Plus size={13} /></ToolbarButton>}<ToolbarButton label={t('common.close')} onClick={() => setBookmarksOpen(false)}><X size={13} /></ToolbarButton></div></div>
|
||||
{editingBookmark ? <BrowserBookmarkEditor key={editingBookmark.id || 'new'} initial={editingBookmark} onSave={browser.mutateBookmark} onCancel={() => setEditingBookmark(null)} readIcon={browser.readIcon} /> : <div className="min-h-0 flex-1 overflow-y-auto p-1">{browser.bookmarks.map((item) => <div key={item.id} className="group flex min-w-0 items-center gap-2 rounded-md px-2 py-2 hover:bg-secondary/40"><BrowserIcon className="h-6 w-6 rounded" image={item.iconMode === 'custom' ? item.iconDataUrl : item.siteIconDataUrl} glyph={item.iconMode === 'custom' ? item.icon : item.siteIconGlyph || item.icon} color={item.color} /><button type="button" className="min-w-0 flex-1 text-left" onClick={() => navigate(item.url)}><p className="truncate text-xs">{item.title}</p><p className="truncate text-[11px] text-muted-foreground">{item.url}</p></button><ToolbarButton label={t('common.edit')} onClick={() => setEditingBookmark(item)}><Edit2 size={12} /></ToolbarButton><ToolbarButton label={t('common.delete')} onClick={() => void browser.mutateBookmark(item.id)}><Trash2 size={12} /></ToolbarButton></div>)}</div>}
|
||||
</aside>}
|
||||
</div>
|
||||
{editingIcon && <BrowserTabIconEditor key={editingIcon.id} tab={editingIcon} readIcon={browser.readIcon} onClose={() => setEditingIcon(null)} onSave={async (values) => { const saved = await browser.command({ type: 'set-icon', tabId: editingIcon.id, ...values }); if (saved) setEditingIcon(null); }} />}
|
||||
</div>;
|
||||
}
|
||||
|
||||
function BrowserTabIconEditor({ tab, readIcon, onClose, onSave }: { tab: HostBrowserTab; readIcon: (file: File) => Promise<string | null>; onClose: () => void; onSave: (values: { glyph: string; color: string; dataUrl?: string | null }) => Promise<void> }) {
|
||||
const { t } = useI18n();
|
||||
const [icon, setIcon] = useState<BrowserIconSelection>({ mode: tab.iconDataUrl ? 'image' : 'text', glyph: tab.iconGlyph || '', color: tab.iconColor || '#247969', image: tab.iconDataUrl });
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [reading, setReading] = useState(false);
|
||||
return <Dialog open onOpenChange={(open) => { if (!open) onClose(); }}>
|
||||
<DialogContent className="max-w-sm">
|
||||
<DialogTitle>{t('terminal.browser.changeIcon')}</DialogTitle>
|
||||
<form className="space-y-4" onSubmit={async (event) => {
|
||||
event.preventDefault(); setSaving(true);
|
||||
try { await onSave({ glyph: icon.mode === 'text' ? icon.glyph : '', color: icon.color, dataUrl: icon.mode === 'image' ? icon.image : null }); }
|
||||
finally { setSaving(false); }
|
||||
}}>
|
||||
<div className="flex min-w-0 items-center gap-3">
|
||||
<BrowserIcon className="h-12 w-12 rounded-xl border border-border/50 bg-muted/40 p-2.5 [&>span]:text-lg" image={icon.mode === 'image' ? icon.image : null} glyph={icon.mode === 'text' ? icon.glyph : null} color={icon.color} />
|
||||
<p className="min-w-0 truncate text-sm font-medium">{tab.title || tab.url}</p>
|
||||
</div>
|
||||
<BrowserIconEditor value={icon} onChange={(changes) => setIcon((current) => ({ ...current, ...changes }))} readIcon={readIcon} onBusyChange={setReading} allowWebsite={false} />
|
||||
<div className="flex justify-end gap-2 border-t border-border/50 pt-3">
|
||||
<Button type="button" variant="ghost" size="sm" onClick={onClose}>{t('common.cancel')}</Button>
|
||||
<Button type="submit" size="sm" disabled={saving || reading || (icon.mode === 'image' ? !icon.image : !icon.glyph.trim())}>{t('common.save')}</Button>
|
||||
</div>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>;
|
||||
}
|
||||
@@ -379,6 +379,12 @@ echo $3 >> "$FILE"`);
|
||||
type: keyType,
|
||||
bits: keySize,
|
||||
comment: `${draftKey.label.trim()}@netcatty`,
|
||||
resident: keyType === "ED25519-SK" || keyType === "ECDSA-SK"
|
||||
? !!(draftKey as { resident?: boolean }).resident
|
||||
: undefined,
|
||||
verifyRequired: keyType === "ED25519-SK" || keyType === "ECDSA-SK"
|
||||
? !!(draftKey as { verifyRequired?: boolean }).verifyRequired
|
||||
: undefined,
|
||||
});
|
||||
if (!result) {
|
||||
throw new Error(
|
||||
@@ -389,15 +395,17 @@ echo $3 >> "$FILE"`);
|
||||
throw new Error(result.error || t("keychain.error.generateKeyPairFailed"));
|
||||
}
|
||||
|
||||
const resolvedType = (result.keyType as KeyType | undefined) || keyType;
|
||||
const isFidoSk = resolvedType === "ED25519-SK" || resolvedType === "ECDSA-SK";
|
||||
const newKey: SSHKey = {
|
||||
id: crypto.randomUUID(),
|
||||
label: draftKey.label.trim(),
|
||||
type: keyType,
|
||||
keySize: keyType !== "ED25519" ? keySize : undefined,
|
||||
type: resolvedType,
|
||||
keySize: resolvedType === "ED25519" || isFidoSk ? undefined : keySize,
|
||||
privateKey: result.privateKey,
|
||||
publicKey: result.publicKey,
|
||||
passphrase: draftKey.passphrase,
|
||||
savePassphrase: draftKey.savePassphrase,
|
||||
passphrase: isFidoSk ? undefined : draftKey.passphrase,
|
||||
savePassphrase: isFidoSk ? undefined : draftKey.savePassphrase,
|
||||
source: "generated",
|
||||
category: "key",
|
||||
created: Date.now(),
|
||||
|
||||
@@ -1,73 +0,0 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import React from "react";
|
||||
import { createDomRenderer, dispatchDomEvent, installDomEnvironment, runWithAct } from "./test-support/renderReactDom.tsx";
|
||||
import type { Snippet } from "../types";
|
||||
|
||||
test("stacked library continues loading after running a script and returning", async (t) => {
|
||||
const env = installDomEnvironment();
|
||||
const previous = Object.getOwnPropertyDescriptor(globalThis, "IntersectionObserver");
|
||||
const previousStorage = Object.getOwnPropertyDescriptor(globalThis, "localStorage");
|
||||
Object.defineProperty(globalThis, "localStorage", { configurable: true, value: env.window.localStorage });
|
||||
const observers: Array<{ target: Element | null; intersect: () => void }> = [];
|
||||
class Observer {
|
||||
target: Element | null = null;
|
||||
constructor(private callback: IntersectionObserverCallback) { observers.push(this); }
|
||||
observe(target: Element) { this.target = target; }
|
||||
disconnect() { this.target = null; }
|
||||
intersect = () => {
|
||||
if (this.target?.isConnected) {
|
||||
this.callback([{ target: this.target, isIntersecting: true } as IntersectionObserverEntry], this as unknown as IntersectionObserver);
|
||||
}
|
||||
};
|
||||
}
|
||||
Object.defineProperty(globalThis, "IntersectionObserver", { configurable: true, value: Observer });
|
||||
const { ScriptsSidePanel } = await import("./ScriptsSidePanel.tsx");
|
||||
const { I18nProvider } = await import("../application/i18n/I18nProvider.tsx");
|
||||
const { SCRIPTS_VIEW_MODE_STORAGE_KEY } = await import("../application/state/useScriptsViewMode.ts");
|
||||
env.window.localStorage.setItem(SCRIPTS_VIEW_MODE_STORAGE_KEY, "stacked");
|
||||
const renderer = await createDomRenderer(env.document);
|
||||
t.after(async () => {
|
||||
await renderer.unmount();
|
||||
if (previous) Object.defineProperty(globalThis, "IntersectionObserver", previous);
|
||||
else Reflect.deleteProperty(globalThis, "IntersectionObserver");
|
||||
if (previousStorage) Object.defineProperty(globalThis, "localStorage", previousStorage);
|
||||
else Reflect.deleteProperty(globalThis, "localStorage");
|
||||
env.cleanup();
|
||||
});
|
||||
const snippets: Snippet[] = Array.from({ length: 300 }, (_, i) => ({
|
||||
id: `script-${i}`, label: `Script ${String(i).padStart(3, "0")}`, command: "echo ok", kind: "script", package: "",
|
||||
}));
|
||||
const executed: string[] = [];
|
||||
await renderer.render(
|
||||
<I18nProvider locale="en"><ScriptsSidePanel snippets={snippets} packages={[]}
|
||||
onSnippetClick={() => assert.fail("scripts use the existing script execution path")}
|
||||
onRunScript={(snippet) => executed.push(snippet.id)} /></I18nProvider>,
|
||||
);
|
||||
const library = () => env.document.querySelector('[data-scripts-view="stacked"]');
|
||||
const chipCount = () => library()?.querySelectorAll("button").length;
|
||||
const button = (label: string) => {
|
||||
const result = Array.from(env.document.querySelectorAll("button")).find((element) => element.textContent === label);
|
||||
assert.ok(result, `missing button ${label}`);
|
||||
return result;
|
||||
};
|
||||
assert.equal(chipCount(), 120);
|
||||
const firstSentinel = observers.at(-1)?.target;
|
||||
assert.ok(firstSentinel?.isConnected);
|
||||
await dispatchDomEvent(button("Script 000"), new env.window.MouseEvent("click", { bubbles: true }));
|
||||
assert.deepEqual(executed, ["script-0"]);
|
||||
assert.equal(library(), null, "execution opens Running");
|
||||
assert.equal(firstSentinel.isConnected, false);
|
||||
await dispatchDomEvent(button("Library"), new env.window.MouseEvent("click", { bubbles: true }));
|
||||
assert.equal(chipCount(), 120);
|
||||
assert.ok(observers.at(-1)?.target?.isConnected, "the new sentinel must be observed");
|
||||
assert.notEqual(observers.at(-1)?.target, firstSentinel);
|
||||
await runWithAct(() => { observers.at(-1)?.intersect(); });
|
||||
assert.equal(chipCount(), 240);
|
||||
await dispatchDomEvent(button("Running"), new env.window.MouseEvent("click", { bubbles: true }));
|
||||
await dispatchDomEvent(button("Library"), new env.window.MouseEvent("click", { bubbles: true }));
|
||||
assert.equal(chipCount(), 240, "returning preserves the loaded window");
|
||||
await runWithAct(() => { observers.at(-1)?.intersect(); });
|
||||
assert.equal(chipCount(), 300);
|
||||
assert.ok(button("Script 299"));
|
||||
});
|
||||
@@ -174,31 +174,3 @@ test("scripts side panel does not start a drag from a non-primary pointer", () =
|
||||
assert.match(source, /isNonPrimaryPointer/);
|
||||
assert.match(source, /primaryOnlyDragHandlers/);
|
||||
});
|
||||
|
||||
test("scripts side panel offers a stacked view toggle that persists the choice", () => {
|
||||
assert.match(source, /useScriptsViewMode/);
|
||||
const toggle = source.match(/viewStacked[\s\S]*?\{viewMode === 'stacked' \? <List size=\{14\} \/> : <LayoutGrid size=\{14\} \/>\}/);
|
||||
assert.ok(toggle, "view toggle button should swap list / stacked icons");
|
||||
assert.match(source, /aria-pressed=\{viewMode === 'stacked'\}/);
|
||||
// The chosen mode persists via the shared hook so it survives app restarts.
|
||||
assert.match(source, /useScriptsViewMode\(\)/);
|
||||
assert.match(source, /setViewMode\(viewMode === 'stacked' \? 'list' : 'stacked'\)/);
|
||||
});
|
||||
|
||||
test("scripts side panel stacked view renders wrap-around chips and keeps run behavior", () => {
|
||||
const stacked = source.match(/viewMode === 'stacked' \? \([\s\S]*?\) : \(/);
|
||||
assert.ok(stacked, "stacked branch should exist in the content area");
|
||||
assert.match(stacked[0], /flex flex-wrap/);
|
||||
// Chips reuse the same click handler (multi-select / run / execute) as rows.
|
||||
assert.match(stacked[0], /onClick=\{\(\) => handleSnippetClick\(snippet\)\}/);
|
||||
// Packages stay visible as section headers with their counts.
|
||||
assert.match(stacked[0], /item\.row\.name/);
|
||||
assert.match(stacked[0], /item\.countLabel/);
|
||||
assert.match(source, /const SnippetChip = memo/);
|
||||
// The stacked chip keeps the same context menu contract (edit / delete / run).
|
||||
const chip = source.match(/const SnippetChip = memo[\s\S]*?SnippetChip\.displayName/);
|
||||
assert.ok(chip, "SnippetChip component should exist");
|
||||
assert.match(chip[0], /ContextMenuContent/);
|
||||
assert.match(chip[0], /onRunParallel/);
|
||||
assert.match(chip[0], /onEdit/);
|
||||
});
|
||||
@@ -15,8 +15,6 @@ import {
|
||||
Expand,
|
||||
FolderPlus,
|
||||
Layers,
|
||||
LayoutGrid,
|
||||
List,
|
||||
Minimize2,
|
||||
Package,
|
||||
Play,
|
||||
@@ -29,7 +27,6 @@ import {
|
||||
import React, { memo, useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore } from 'react';
|
||||
import { useI18n } from '../application/i18n/I18nProvider';
|
||||
import { getScriptRecordingSnapshot, subscribeScriptRecording } from '../application/state/scriptRecordingStore.ts';
|
||||
import { useScriptsViewMode } from '../application/state/useScriptsViewMode.ts';
|
||||
import { VaultDeleteConfirmDialog } from './vault/VaultDeleteConfirmDialog';
|
||||
import {
|
||||
collectSnippetPackageTreePaths,
|
||||
@@ -64,12 +61,6 @@ const toolbarIconButtonClass =
|
||||
'h-7 w-7 shrink-0 flex items-center justify-center rounded-md text-muted-foreground hover:text-foreground hover:bg-muted/60 transition-colors disabled:opacity-40 disabled:pointer-events-none';
|
||||
|
||||
const SCRIPT_ROW_HEIGHT = 34;
|
||||
// The stacked view renders wrapping chips that cannot use FixedSizeVirtualList,
|
||||
// so cap the initial render and grow lazily as the user scrolls to keep a huge
|
||||
// library (or a broad search) from mounting every chip at once.
|
||||
const STACKED_INITIAL_RENDER_COUNT = 120;
|
||||
const STACKED_RENDER_BATCH = 120;
|
||||
|
||||
const isRootPackagePath = (path: string): boolean => {
|
||||
const body = path.startsWith('/') ? path.slice(1) : path;
|
||||
return body.length > 0 && !body.includes('/');
|
||||
@@ -179,14 +170,10 @@ export function buildScriptsSidePanelRows({
|
||||
snippets,
|
||||
packages,
|
||||
expandedPaths,
|
||||
snippetsBeforeChildren = false,
|
||||
}: {
|
||||
snippets: Snippet[];
|
||||
packages: string[];
|
||||
expandedPaths: Set<string>;
|
||||
/** Emit each package's own snippets before its sub-packages so flat views
|
||||
* (stacked chips) keep every package's chips attached to its section. */
|
||||
snippetsBeforeChildren?: boolean;
|
||||
}): TreeRow[] {
|
||||
const normalizedPackages = new Set(collectScriptsSidePanelPackagePaths(packages, snippets));
|
||||
|
||||
@@ -263,13 +250,6 @@ export function buildScriptsSidePanelRows({
|
||||
});
|
||||
|
||||
if (!isExpanded) return;
|
||||
if (snippetsBeforeChildren) {
|
||||
// Flat stacked view: a package's chips must follow its own section
|
||||
// header, not trail after its nested children's chips.
|
||||
emitSnippets(localSnippets, pkg, depth);
|
||||
children.forEach((child) => walk(child, depth + 1));
|
||||
return;
|
||||
}
|
||||
children.forEach((child) => walk(child, depth + 1));
|
||||
emitSnippets(localSnippets, pkg, depth);
|
||||
};
|
||||
@@ -303,7 +283,6 @@ const ScriptsSidePanelInner: React.FC<ScriptsSidePanelProps> = ({
|
||||
const [search, setSearch] = useState('');
|
||||
const [searchExpanded, setSearchExpanded] = useState(false);
|
||||
const [subView, setSubView] = useState<'library' | 'running'>('library');
|
||||
const [viewMode, setViewMode] = useScriptsViewMode();
|
||||
const [expandedPaths, setExpandedPaths] = useState<Set<string>>(new Set());
|
||||
const [isMultiSelectMode, setIsMultiSelectMode] = useState(false);
|
||||
const [selectedSnippetIds, setSelectedSnippetIds] = useState<Set<string>>(new Set());
|
||||
@@ -314,7 +293,6 @@ const ScriptsSidePanelInner: React.FC<ScriptsSidePanelProps> = ({
|
||||
const [renamingPackagePath, setRenamingPackagePath] = useState('');
|
||||
const [newPackageName, setNewPackageName] = useState('');
|
||||
const [packageError, setPackageError] = useState('');
|
||||
const [stackedRenderLimit, setStackedRenderLimit] = useState(STACKED_INITIAL_RENDER_COUNT);
|
||||
const packageDialogRef = useRef<HTMLDivElement>(null);
|
||||
const packageNameInputRef = useRef<HTMLInputElement>(null);
|
||||
const searchInputRef = useRef<HTMLInputElement>(null);
|
||||
@@ -509,11 +487,8 @@ const ScriptsSidePanelInner: React.FC<ScriptsSidePanelProps> = ({
|
||||
snippets,
|
||||
packages,
|
||||
expandedPaths,
|
||||
// Stacked chips wrap flat, so each package's chips must stay attached
|
||||
// to its own section header.
|
||||
snippetsBeforeChildren: viewMode === 'stacked',
|
||||
});
|
||||
}, [snippets, packages, expandedPaths, searchMatches, isVisible, viewMode]);
|
||||
}, [snippets, packages, expandedPaths, searchMatches, isVisible]);
|
||||
|
||||
type ScriptsListItem =
|
||||
| { key: string; kind: 'search'; snippet: Snippet }
|
||||
@@ -546,48 +521,6 @@ const ScriptsSidePanelInner: React.FC<ScriptsSidePanelProps> = ({
|
||||
});
|
||||
}, [rows, searchMatches, t, isVisible]);
|
||||
|
||||
const stackedSentinelRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
// Restart the lazy window whenever the underlying result set changes
|
||||
// (new search, package list changes, snippet edits, view-mode switch) — but
|
||||
// NOT on package expand/collapse. Expanding/collapsing only reshapes the
|
||||
// same result set; resetting there would drop the user's lazy-loaded window
|
||||
// and remount the package they just toggled, forcing them to re-scroll.
|
||||
// Keyed by data identity, not by listItems reference, for that reason.
|
||||
// Reset during render — not in an effect — so the first render of a new
|
||||
// result set never slices with the previous, potentially very large window.
|
||||
const stackedResultSetKey = useMemo(() => {
|
||||
if (searchMatches !== null) {
|
||||
return `search:${searchMatches.map((snippet) => snippet.id).join('\u0000')}`;
|
||||
}
|
||||
return `tree:${viewMode}:${packages.join('\u0000')}:${snippets
|
||||
.map((snippet) => snippet.id)
|
||||
.join('\u0000')}`;
|
||||
}, [searchMatches, viewMode, packages, snippets]);
|
||||
|
||||
const [prevResultSetKey, setPrevResultSetKey] = useState(stackedResultSetKey);
|
||||
if (prevResultSetKey !== stackedResultSetKey) {
|
||||
setPrevResultSetKey(stackedResultSetKey);
|
||||
setStackedRenderLimit(STACKED_INITIAL_RENDER_COUNT);
|
||||
}
|
||||
|
||||
const hasMoreStackedItems = viewMode === 'stacked' && stackedRenderLimit < listItems.length;
|
||||
|
||||
useEffect(() => {
|
||||
if (subView !== 'library' || !hasMoreStackedItems) return;
|
||||
const sentinel = stackedSentinelRef.current;
|
||||
if (!sentinel) return;
|
||||
// Re-created per batch: observing always emits an initial intersection
|
||||
// callback, so short result sets that never scroll keep loading.
|
||||
const observer = new IntersectionObserver((entries) => {
|
||||
if (entries.some((entry) => entry.isIntersecting)) {
|
||||
setStackedRenderLimit((limit) => limit + STACKED_RENDER_BATCH);
|
||||
}
|
||||
});
|
||||
observer.observe(sentinel);
|
||||
return () => observer.disconnect();
|
||||
}, [hasMoreStackedItems, stackedRenderLimit, subView]);
|
||||
|
||||
const handleSnippetClick = useCallback(
|
||||
(snippet: Snippet) => {
|
||||
if (isMultiSelectMode) {
|
||||
@@ -1046,30 +979,6 @@ const ScriptsSidePanelInner: React.FC<ScriptsSidePanelProps> = ({
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">{t('snippets.searchPlaceholder')}</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
toolbarIconButtonClass,
|
||||
viewMode === 'stacked' && 'bg-muted/70 text-foreground',
|
||||
)}
|
||||
disabled={!hasAnyContent}
|
||||
aria-label={viewMode === 'stacked'
|
||||
? t('scripts.sidePanel.viewList')
|
||||
: t('scripts.sidePanel.viewStacked')}
|
||||
aria-pressed={viewMode === 'stacked'}
|
||||
onClick={() => setViewMode(viewMode === 'stacked' ? 'list' : 'stacked')}
|
||||
>
|
||||
{viewMode === 'stacked' ? <List size={14} /> : <LayoutGrid size={14} />}
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">
|
||||
{viewMode === 'stacked'
|
||||
? t('scripts.sidePanel.viewList')
|
||||
: t('scripts.sidePanel.viewStacked')}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
@@ -1217,107 +1126,6 @@ const ScriptsSidePanelInner: React.FC<ScriptsSidePanelProps> = ({
|
||||
<div className="px-3 py-4 text-xs text-muted-foreground italic text-center">
|
||||
{t('common.noResultsFound')}
|
||||
</div>
|
||||
) : viewMode === 'stacked' ? (
|
||||
<div
|
||||
className="h-full overflow-auto px-2 py-1.5"
|
||||
data-scripts-view="stacked"
|
||||
>
|
||||
<div className="flex flex-wrap items-center content-start gap-1.5">
|
||||
{listItems.slice(0, stackedRenderLimit).map((item) => {
|
||||
if (item.kind === 'package') {
|
||||
// Mirror the list view's PackageRow context menu so stacked
|
||||
// mode keeps rename/delete reachable without switching views.
|
||||
const canMutatePackages = Boolean(onPackagesChange && onSnippetsChange);
|
||||
const header = (
|
||||
<button
|
||||
key={item.key}
|
||||
type="button"
|
||||
onClick={() => togglePackage(item.row.path)}
|
||||
aria-expanded={item.row.isExpanded}
|
||||
className="w-full basis-full flex items-center gap-1.5 pt-1.5 pb-0.5 text-left rounded-sm hover:bg-accent/40 transition-colors"
|
||||
style={{ paddingLeft: 8 + Math.min(item.row.depth, 4) * 10 }}
|
||||
>
|
||||
<ChevronRight
|
||||
size={11}
|
||||
className={cn(
|
||||
'shrink-0 text-muted-foreground transition-transform',
|
||||
item.row.isExpanded && 'rotate-90',
|
||||
!item.row.hasChildren && 'opacity-0',
|
||||
)}
|
||||
/>
|
||||
<Package size={11} className="shrink-0 text-primary/80" />
|
||||
<span className="min-w-0 truncate text-[10px] font-medium text-muted-foreground">
|
||||
{item.row.name}
|
||||
</span>
|
||||
<span className="shrink-0 text-[10px] text-muted-foreground tabular-nums">
|
||||
{item.countLabel}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
if (!canMutatePackages) {
|
||||
return header;
|
||||
}
|
||||
return (
|
||||
<ContextMenu key={item.key}>
|
||||
<ContextMenuTrigger asChild>
|
||||
{header}
|
||||
</ContextMenuTrigger>
|
||||
<ContextMenuContent>
|
||||
<ContextMenuItem
|
||||
onClick={() => openRenamePackageDialog(item.row.path)}
|
||||
>
|
||||
<Edit2 className="mr-2 h-4 w-4" /> {t('common.rename')}
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem
|
||||
className="text-destructive"
|
||||
onClick={() => requestDeletePackage(item.row.path)}
|
||||
>
|
||||
<Trash2 className="mr-2 h-4 w-4" /> {t('action.delete')}
|
||||
</ContextMenuItem>
|
||||
</ContextMenuContent>
|
||||
</ContextMenu>
|
||||
);
|
||||
}
|
||||
const snippet = item.kind === 'search' ? item.snippet : item.row.snippet;
|
||||
const isScript = isScriptSnippet(snippet);
|
||||
// Search results drop the package headers, so surface each
|
||||
// snippet's package on the chip itself to keep results
|
||||
// distinguishable (labels are not unique across packages).
|
||||
const chipSubtitle = item.kind === 'search'
|
||||
? (snippet.package || t('terminal.toolbar.library'))
|
||||
: undefined;
|
||||
return (
|
||||
<SnippetChip
|
||||
key={item.key}
|
||||
snippet={snippet}
|
||||
subtitle={chipSubtitle}
|
||||
selected={selectedSnippetIds.has(snippet.id)}
|
||||
multiSelect={isMultiSelectMode}
|
||||
onClick={() => handleSnippetClick(snippet)}
|
||||
onEdit={() => handleEditSnippet(snippet)}
|
||||
onDelete={() => handleDeleteSnippet(snippet.id)}
|
||||
onCopyCommand={() => handleCopySnippetCommand(snippet)}
|
||||
copyCommandLabel={t('scripts.actions.copyCommand')}
|
||||
onRunParallel={onRunScriptOnWorkspace
|
||||
? () => onRunScriptOnWorkspace(snippet, 'parallel')
|
||||
: undefined}
|
||||
onRunSequential={isScript && onRunScriptOnWorkspace
|
||||
? () => onRunScriptOnWorkspace(snippet, 'sequential')
|
||||
: undefined}
|
||||
runParallelLabel={isScript
|
||||
? t('scripts.actions.runParallel')
|
||||
: t('scripts.actions.runOnAllTabs')}
|
||||
runSequentialLabel={t('scripts.actions.runSequential')}
|
||||
editLabel={t('action.edit')}
|
||||
deleteLabel={t('action.delete')}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{hasMoreStackedItems ? (
|
||||
<div ref={stackedSentinelRef} className="h-px w-full" aria-hidden="true" />
|
||||
) : null}
|
||||
</div>
|
||||
) : (
|
||||
<FixedSizeVirtualList
|
||||
className="h-full"
|
||||
@@ -1842,117 +1650,5 @@ const SnippetRow = memo<SnippetRowProps>(({
|
||||
));
|
||||
SnippetRow.displayName = 'SnippetRow';
|
||||
|
||||
interface SnippetChipProps {
|
||||
snippet: Snippet;
|
||||
/** Optional package context shown on the chip (used by stacked search
|
||||
* results, where the package headers are dropped). */
|
||||
subtitle?: string;
|
||||
selected?: boolean;
|
||||
multiSelect?: boolean;
|
||||
onClick: () => void;
|
||||
onEdit: () => void;
|
||||
onDelete: () => void;
|
||||
onRunParallel?: () => void;
|
||||
onRunSequential?: () => void;
|
||||
onCopyCommand?: () => void;
|
||||
copyCommandLabel?: string;
|
||||
runParallelLabel?: string;
|
||||
runSequentialLabel?: string;
|
||||
editLabel: string;
|
||||
deleteLabel: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Compact chip for the stacked (wrap) view. Shares the same click → run /
|
||||
* execute and right-click context menu as the list rows; only the layout is
|
||||
* denser. Drag reordering stays list-view-only.
|
||||
*/
|
||||
const SnippetChip = memo<SnippetChipProps>(({
|
||||
snippet,
|
||||
subtitle,
|
||||
selected = false,
|
||||
multiSelect = false,
|
||||
onClick,
|
||||
onEdit,
|
||||
onDelete,
|
||||
onRunParallel,
|
||||
onRunSequential,
|
||||
onCopyCommand,
|
||||
copyCommandLabel,
|
||||
runParallelLabel,
|
||||
runSequentialLabel,
|
||||
editLabel,
|
||||
deleteLabel,
|
||||
}) => (
|
||||
<ContextMenu>
|
||||
{/* Radix ContextMenuTrigger needs a real DOM element (Tooltip.Root renders
|
||||
none), so mirror SnippetRow and wrap the tooltip in a plain div. */}
|
||||
<ContextMenuTrigger asChild>
|
||||
<div className="min-w-0 max-w-full">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
aria-pressed={multiSelect ? selected : undefined}
|
||||
className={cn(
|
||||
'h-6 max-w-full min-w-0 px-2 rounded-md border border-border/60 bg-muted/40 hover:bg-accent/60 text-[11px] flex items-center gap-1 transition-colors',
|
||||
selected && 'bg-primary/10 border-primary/40 hover:bg-primary/15',
|
||||
)}
|
||||
>
|
||||
{multiSelect ? (
|
||||
<CheckSquare
|
||||
size={11}
|
||||
className={cn('shrink-0', selected ? 'text-primary' : 'text-muted-foreground/70')}
|
||||
/>
|
||||
) : isScriptSnippet(snippet) ? (
|
||||
<Play size={11} className="shrink-0 text-primary" />
|
||||
) : (
|
||||
<Zap size={11} className="shrink-0 text-muted-foreground" />
|
||||
)}
|
||||
<span className="min-w-0 truncate">{snippet.label}</span>
|
||||
{subtitle ? (
|
||||
<>
|
||||
<span aria-hidden="true" className="shrink-0 text-muted-foreground/50">·</span>
|
||||
<span className="min-w-0 truncate text-[10px] text-muted-foreground/80">
|
||||
{subtitle}
|
||||
</span>
|
||||
</>
|
||||
) : null}
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right" align="start">
|
||||
<SnippetCommandTooltipContent label={snippet.label} command={snippet.command} />
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</ContextMenuTrigger>
|
||||
<ContextMenuContent>
|
||||
{onRunParallel ? (
|
||||
<ContextMenuItem onClick={onRunParallel}>
|
||||
<Layers className="mr-2 h-4 w-4" /> {runParallelLabel}
|
||||
</ContextMenuItem>
|
||||
) : null}
|
||||
{onRunSequential ? (
|
||||
<ContextMenuItem onClick={onRunSequential}>
|
||||
<Layers className="mr-2 h-4 w-4" /> {runSequentialLabel}
|
||||
</ContextMenuItem>
|
||||
) : null}
|
||||
{onCopyCommand ? (
|
||||
<ContextMenuItem onClick={onCopyCommand}>
|
||||
<Copy className="mr-2 h-4 w-4" /> {copyCommandLabel}
|
||||
</ContextMenuItem>
|
||||
) : null}
|
||||
<ContextMenuItem onClick={onEdit}>
|
||||
<Edit2 className="mr-2 h-4 w-4" /> {editLabel}
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem className="text-destructive" onClick={onDelete}>
|
||||
<Trash2 className="mr-2 h-4 w-4" /> {deleteLabel}
|
||||
</ContextMenuItem>
|
||||
</ContextMenuContent>
|
||||
</ContextMenu>
|
||||
));
|
||||
SnippetChip.displayName = 'SnippetChip';
|
||||
|
||||
export const ScriptsSidePanel = memo(ScriptsSidePanelInner);
|
||||
ScriptsSidePanel.displayName = 'ScriptsSidePanel';
|
||||
@@ -0,0 +1,82 @@
|
||||
import React, { useId, useState } from 'react';
|
||||
import { Check, Globe, Loader2, Monitor } from 'lucide-react';
|
||||
import type { BrowserBookmark } from '../../domain/browserBookmark';
|
||||
import type { HostBrowserBookmark } from '../../domain/hostBrowser';
|
||||
import { useI18n } from '../../application/i18n/I18nProvider';
|
||||
import { cn } from '../../lib/utils';
|
||||
import { Button } from '../ui/button';
|
||||
import { Input } from '../ui/input';
|
||||
import { Label } from '../ui/label';
|
||||
import { AsidePanelContent, AsidePanelFooter } from '../ui/aside-panel';
|
||||
import { BrowserIcon } from './BrowserIcon';
|
||||
import { BrowserIconEditor, type BrowserIconSelection } from './BrowserIconEditor';
|
||||
|
||||
export function BrowserBookmarkEditor({ initial, onSave, onCancel, readIcon }: {
|
||||
initial: Partial<HostBrowserBookmark>;
|
||||
onSave: (bookmark: Partial<BrowserBookmark>) => Promise<boolean>;
|
||||
onCancel: () => void;
|
||||
readIcon: (file: File) => Promise<string | null>;
|
||||
}) {
|
||||
const { t } = useI18n();
|
||||
const id = useId();
|
||||
const [draft, setDraft] = useState(initial);
|
||||
const [icon, setIcon] = useState<BrowserIconSelection>({
|
||||
mode: initial.iconDataUrl ? 'image' : initial.iconMode === 'custom' ? 'text' : 'website',
|
||||
glyph: initial.icon || '', color: initial.color || '#247969', image: initial.iconDataUrl || null,
|
||||
});
|
||||
const [reading, setReading] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const invalidIcon = reading || (icon.mode === 'image' && !icon.image);
|
||||
const previewImage = icon.mode === 'image' ? icon.image : icon.mode === 'website' ? initial.siteIconDataUrl : null;
|
||||
const previewGlyph = icon.mode === 'text' ? icon.glyph || draft.title?.trim().slice(0, 1) : icon.mode === 'website' ? initial.siteIconGlyph : null;
|
||||
let addressHint = t('terminal.browser.bookmarkPreviewHint');
|
||||
try { if (draft.url) addressHint = new URL(draft.url).host; } catch { /* Keep the hint while an address is being typed. */ }
|
||||
|
||||
return <form className="flex min-h-0 flex-1 flex-col" data-section="browser-bookmark-editor" onSubmit={async (event) => {
|
||||
event.preventDefault();
|
||||
if (invalidIcon || saving) return;
|
||||
setSaving(true);
|
||||
try {
|
||||
const saved = await onSave({ ...draft, title: draft.title?.trim(), url: draft.url?.trim(),
|
||||
color: icon.color, iconMode: icon.mode === 'website' ? 'auto' : 'custom',
|
||||
iconDataUrl: icon.mode === 'image' ? icon.image : null, icon: icon.mode === 'text' ? icon.glyph : '' });
|
||||
if (saved) onCancel();
|
||||
} finally { setSaving(false); }
|
||||
}}>
|
||||
<AsidePanelContent className="min-h-0">
|
||||
<div className="flex min-w-0 items-center gap-3 border-b border-border/40 pb-4" data-section="bookmark-preview">
|
||||
<BrowserIcon className="h-12 w-12 rounded-xl border border-border/50 bg-muted/40 p-2.5 [&>span]:text-lg [&>svg]:h-5 [&>svg]:w-5" image={previewImage} glyph={previewGlyph} color={icon.mode === 'website' ? initial.siteIconColor : icon.color} />
|
||||
<div className="min-w-0 flex-1"><p className="truncate text-sm font-medium">{draft.title?.trim() || t('terminal.browser.untitledBookmark')}</p><p className="mt-1 truncate text-xs text-muted-foreground">{addressHint}</p></div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={`${id}-title`} className="text-xs text-muted-foreground">{t('terminal.browser.bookmarkTitle')}</Label>
|
||||
<Input id={`${id}-title`} autoFocus required maxLength={120} className="h-9" placeholder={t('terminal.browser.bookmarkTitlePlaceholder')} value={draft.title || ''} onChange={(event) => setDraft((current) => ({ ...current, title: event.target.value }))} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={`${id}-url`} className="text-xs text-muted-foreground">{t('terminal.browser.websiteAddress')}</Label>
|
||||
<Input id={`${id}-url`} required type="url" className="h-9" placeholder="https://example.com" spellCheck={false} autoComplete="off" value={draft.url || ''} onChange={(event) => setDraft((current) => ({ ...current, url: event.target.value }))} />
|
||||
</div>
|
||||
<div className="space-y-2.5">
|
||||
<p className="text-xs font-medium text-muted-foreground">{t('terminal.browser.icon')}</p>
|
||||
<BrowserIconEditor value={icon} onChange={(changes) => setIcon((current) => ({ ...current, ...changes }))} readIcon={readIcon} onBusyChange={setReading} />
|
||||
</div>
|
||||
<fieldset className="min-w-0 space-y-2.5">
|
||||
<legend className="mb-2.5 text-xs font-medium text-muted-foreground">{t('terminal.browser.scope')}</legend>
|
||||
{(['host', 'all-hosts'] as const).map((scope) => {
|
||||
const selected = (draft.scope || 'host') === scope;
|
||||
const Icon = scope === 'host' ? Monitor : Globe;
|
||||
return <label key={scope} className={cn('relative flex cursor-pointer items-center gap-3 rounded-lg border px-3 py-2.5 transition-colors focus-within:ring-1 focus-within:ring-ring', selected ? 'border-primary/35 bg-primary/5' : 'border-border/50 hover:bg-muted/30')}>
|
||||
<input type="radio" name={`${id}-scope`} value={scope} checked={selected} onChange={() => setDraft((current) => ({ ...current, scope }))} className="sr-only" />
|
||||
<Icon size={15} className={cn('shrink-0', selected ? 'text-primary' : 'text-muted-foreground')} />
|
||||
<span className="min-w-0 flex-1"><span className="block text-xs font-medium">{t(scope === 'host' ? 'terminal.browser.hostScope' : 'terminal.browser.allScope')}</span><span className="mt-1 block text-[11px] leading-relaxed text-muted-foreground">{t(scope === 'host' ? 'terminal.browser.hostScopeHint' : 'terminal.browser.allScopeHint')}</span></span>
|
||||
<span className={cn('grid h-4 w-4 shrink-0 place-items-center rounded-full border', selected ? 'border-primary bg-primary text-primary-foreground' : 'border-muted-foreground/40')}>{selected && <Check size={10} />}</span>
|
||||
</label>;
|
||||
})}
|
||||
</fieldset>
|
||||
</AsidePanelContent>
|
||||
<AsidePanelFooter className="flex justify-end gap-2">
|
||||
<Button type="button" variant="ghost" size="sm" onClick={onCancel}>{t('common.cancel')}</Button>
|
||||
<Button type="submit" size="sm" className="min-w-24" disabled={saving || invalidIcon}>{saving && <Loader2 size={13} className="mr-1.5 animate-spin" />}{t(initial.id ? 'common.save' : 'terminal.browser.addBookmark')}</Button>
|
||||
</AsidePanelFooter>
|
||||
</form>;
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import React from 'react';
|
||||
import { File, FolderOpen, MoreHorizontal, X } from 'lucide-react';
|
||||
import type { HostBrowserDownload } from '../../domain/hostBrowser';
|
||||
import type { useHostBrowserControls } from '../../application/state/useHostBrowserControls';
|
||||
import { useI18n } from '../../application/i18n/I18nProvider';
|
||||
import { Button } from '../ui/button';
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '../ui/popover';
|
||||
|
||||
function size(bytes: number) {
|
||||
const units = ['B', 'KB', 'MB', 'GB'];
|
||||
const index = bytes > 0 ? Math.min(3, Math.floor(Math.log(bytes) / Math.log(1024))) : 0;
|
||||
return `${(bytes / 1024 ** index).toFixed(index ? 1 : 0)} ${units[index]}`;
|
||||
}
|
||||
|
||||
export function BrowserDownloads({ items, command }: { items: HostBrowserDownload[]; command: ReturnType<typeof useHostBrowserControls>['downloadCommand'] }) {
|
||||
const { t } = useI18n();
|
||||
return <div className="min-w-0">
|
||||
<div className="flex items-center justify-between gap-2 border-b border-border/50 px-3 py-2"><div className="min-w-0"><p className="text-xs font-medium">{t('terminal.browser.downloads')}</p><p className="mt-1 text-[11px] text-muted-foreground">{t('terminal.browser.downloadLocation')}</p></div><Button size="icon" variant="ghost" className="h-6 w-6 shrink-0" aria-label={t('terminal.browser.openFolder')} title={t('terminal.browser.openFolder')} onClick={() => void command('open-folder')}><FolderOpen size={14} /></Button></div>
|
||||
<div className="max-h-80 overflow-y-auto p-1">
|
||||
{!items.length && <p className="px-3 py-8 text-center text-xs text-muted-foreground">{t('terminal.browser.downloadEmpty')}</p>}
|
||||
{items.map((item) => {
|
||||
const running = item.status === 'waiting' || item.status === 'progressing';
|
||||
const canOpen = item.status === 'completed' && !item.missing;
|
||||
return <div key={item.id} data-download-id={item.id} className="flex min-w-0 items-center gap-2 rounded-md px-2 py-2 hover:bg-secondary/50">
|
||||
<File size={16} className="shrink-0 text-muted-foreground" /><div className="min-w-0 flex-1"><p className="truncate text-xs" title={item.filename}>{item.filename}</p><p className="mt-0.5 text-[11px] text-muted-foreground">{t(`terminal.browser.${item.missing ? 'missing' : item.status}`)}{item.status === 'progressing' ? ` · ${size(item.received)}${item.total ? ` / ${size(item.total)}` : ''}` : item.status === 'completed' ? ` · ${size(item.total || item.received)}` : ''}</p>
|
||||
{item.status === 'progressing' && <div className="mt-1 h-1 overflow-hidden rounded bg-secondary"><div className={`h-full bg-primary${item.total ? '' : ' animate-pulse'}`} style={{ width: `${item.total ? Math.min(100, item.received * 100 / item.total) : 20}%` }} /></div>}</div>
|
||||
{running ? <Button variant="ghost" size="icon" className="h-6 w-6 shrink-0" aria-label={t('terminal.browser.cancelDownload')} title={t('terminal.browser.cancelDownload')} onClick={() => void command('cancel', item.id)}><X size={13} /></Button>
|
||||
: canOpen ? <Button variant="ghost" size="icon" className="h-6 w-6 shrink-0" title={t('terminal.browser.reveal')} onClick={() => void command('reveal', item.id)}><FolderOpen size={13} /></Button> : null}
|
||||
<Popover><PopoverTrigger asChild><Button variant="ghost" size="icon" className="h-6 w-6 shrink-0" aria-label={t('terminal.browser.more')}><MoreHorizontal size={13} /></Button></PopoverTrigger><PopoverContent align="end" className="w-40 p-1">
|
||||
{canOpen && <Button variant="ghost" size="sm" className="w-full justify-start text-xs" onClick={() => void command('open', item.id)}>{t('terminal.browser.openFile')}</Button>}
|
||||
{canOpen && <Button variant="ghost" size="sm" className="w-full justify-start text-xs" onClick={() => void command('save-as', item.id)}>{t('terminal.browser.saveAs')}</Button>}
|
||||
{canOpen && <Button variant="ghost" size="sm" className="w-full justify-start text-xs" onClick={() => void command('reveal', item.id)}>{t('terminal.browser.reveal')}</Button>}
|
||||
{!running && <Button variant="ghost" size="sm" className="w-full justify-start text-xs" onClick={() => void command('remove', item.id)}>{t('terminal.browser.removeDownload')}</Button>}
|
||||
{running && <Button variant="ghost" size="sm" className="w-full justify-start text-xs" onClick={() => void command('cancel', item.id)}>{t('terminal.browser.cancelDownload')}</Button>}
|
||||
</PopoverContent></Popover>
|
||||
</div>;
|
||||
})}
|
||||
</div>
|
||||
{items.some((item) => !['waiting', 'progressing'].includes(item.status)) && <div className="border-t border-border/50 p-1"><Button variant="ghost" size="sm" className="w-full text-xs" onClick={() => void command('clear')}>{t('terminal.browser.clearDownloads')}</Button></div>}
|
||||
</div>;
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import React from 'react';
|
||||
import { Globe } from 'lucide-react';
|
||||
|
||||
export function BrowserIcon({ image, glyph, color, className = '' }: {
|
||||
image?: string | null; glyph?: string | null; color?: string | null; className?: string;
|
||||
}) {
|
||||
return <span className={`inline-flex shrink-0 items-center justify-center overflow-hidden ${className}`}>
|
||||
{image ? <img src={image} alt="" className="h-full w-full rounded-[20%] object-contain" draggable={false} />
|
||||
: glyph ? <span className="truncate text-xs" style={{ color: color || undefined }}>{glyph}</span>
|
||||
: <Globe size={14} className="text-muted-foreground" />}
|
||||
</span>;
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
import React, { useEffect, useId, useRef, useState } from 'react';
|
||||
import { Check, Globe, ImagePlus, Loader2, Pipette, Type, Upload, X } from 'lucide-react';
|
||||
import { HostBrowserIconError } from '../../domain/hostBrowser';
|
||||
import { useI18n } from '../../application/i18n/I18nProvider';
|
||||
import { cn } from '../../lib/utils';
|
||||
import { Button } from '../ui/button';
|
||||
import { Input } from '../ui/input';
|
||||
import { Label } from '../ui/label';
|
||||
|
||||
export interface BrowserIconSelection {
|
||||
mode: 'website' | 'text' | 'image';
|
||||
glyph: string;
|
||||
color: string;
|
||||
image: string | null;
|
||||
}
|
||||
|
||||
const COLORS = ['#247969', '#2563eb', '#7c3aed', '#c026d3', '#e11d48', '#ea580c', '#ca8a04', '#64748b'];
|
||||
const MODES = [{ value: 'website', Icon: Globe }, { value: 'text', Icon: Type }, { value: 'image', Icon: ImagePlus }] as const;
|
||||
|
||||
export function BrowserIconEditor({ value, onChange, readIcon, onBusyChange, allowWebsite = true }: {
|
||||
value: BrowserIconSelection;
|
||||
onChange: (changes: Partial<BrowserIconSelection>) => void;
|
||||
readIcon: (file: File) => Promise<string | null>;
|
||||
onBusyChange: (busy: boolean) => void;
|
||||
allowWebsite?: boolean;
|
||||
}) {
|
||||
const { t } = useI18n();
|
||||
const inputId = useId();
|
||||
const fileInput = useRef<HTMLInputElement>(null);
|
||||
const readRevision = useRef(0);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [dragging, setDragging] = useState(false);
|
||||
const [filename, setFilename] = useState('');
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
useEffect(() => () => { readRevision.current++; }, []);
|
||||
|
||||
const selectFile = async (file?: File) => {
|
||||
if (!file) return;
|
||||
const revision = ++readRevision.current;
|
||||
setError(null); setBusy(true); onBusyChange(true);
|
||||
try {
|
||||
const image = await readIcon(file);
|
||||
if (revision !== readRevision.current) return;
|
||||
if (!image) throw new HostBrowserIconError('decode');
|
||||
onChange({ image }); setFilename(file.name);
|
||||
} catch (reason) {
|
||||
if (revision === readRevision.current) {
|
||||
const key = reason instanceof HostBrowserIconError ? reason.reason : 'decode';
|
||||
setError(t(`terminal.browser.iconError.${key}`));
|
||||
}
|
||||
} finally {
|
||||
if (revision === readRevision.current) { setBusy(false); onBusyChange(false); }
|
||||
}
|
||||
};
|
||||
|
||||
return <div className="space-y-3" data-section="browser-icon-editor">
|
||||
<div className="grid grid-flow-col auto-cols-fr gap-1 rounded-md bg-muted/50 p-1" role="group" aria-label={t('terminal.browser.icon')}>
|
||||
{MODES.filter((mode) => allowWebsite || mode.value !== 'website').map(({ value: mode, Icon }) => <button
|
||||
key={mode} type="button" aria-pressed={value.mode === mode} disabled={busy}
|
||||
className={cn('flex min-w-0 items-center justify-center gap-1.5 rounded px-2 py-1.5 text-xs font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:opacity-50',
|
||||
value.mode === mode ? 'bg-background text-foreground shadow-sm' : 'text-muted-foreground hover:text-foreground')}
|
||||
onClick={() => { onChange({ mode }); setError(null); }}>
|
||||
<Icon size={13} className="shrink-0" /><span className="truncate">{t(`terminal.browser.${mode}`)}</span>
|
||||
</button>)}
|
||||
</div>
|
||||
{value.mode === 'website' && <p className="text-xs leading-relaxed text-muted-foreground">{t('terminal.browser.websiteIconHint')}</p>}
|
||||
{value.mode === 'text' && <div className="space-y-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<Input id={inputId} aria-label={t('terminal.browser.iconText')} className="h-9 w-20 shrink-0 text-center text-base" maxLength={4} value={value.glyph}
|
||||
onChange={(event) => onChange({ glyph: event.target.value })} placeholder="A" />
|
||||
<Label htmlFor={inputId} className="text-xs font-normal leading-relaxed text-muted-foreground">{t('terminal.browser.iconTextHint')}</Label>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2" role="group" aria-label={t('terminal.browser.color')}>
|
||||
{COLORS.map((color) => <button key={color} type="button" aria-label={t('terminal.browser.selectColor', { color })} aria-pressed={value.color.toLowerCase() === color}
|
||||
className={cn('grid h-6 w-6 place-items-center rounded-full border-2 border-background transition-shadow focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring', value.color.toLowerCase() === color && 'ring-1 ring-foreground/40')}
|
||||
style={{ backgroundColor: color }} onClick={() => onChange({ color })}>
|
||||
{value.color.toLowerCase() === color && <Check size={12} className="text-white" />}
|
||||
</button>)}
|
||||
<label className="relative grid h-6 w-6 cursor-pointer place-items-center rounded-full border border-border text-muted-foreground hover:text-foreground focus-within:ring-1 focus-within:ring-ring" title={t('terminal.browser.customColor')}>
|
||||
<Pipette size={12} /><input aria-label={t('terminal.browser.customColor')} type="color" className="absolute inset-0 h-full w-full cursor-pointer opacity-0" value={value.color} onChange={(event) => onChange({ color: event.target.value })} />
|
||||
</label>
|
||||
</div>
|
||||
</div>}
|
||||
{value.mode === 'image' && <div className="space-y-2" onDragOver={(event) => { event.preventDefault(); setDragging(true); }} onDragLeave={(event) => { if (!event.currentTarget.contains(event.relatedTarget as Node | null)) setDragging(false); }}
|
||||
onDrop={(event) => { event.preventDefault(); setDragging(false); if (!busy) void selectFile(event.dataTransfer.files[0]); }}>
|
||||
<input ref={fileInput} type="file" className="hidden" aria-label={t('terminal.browser.chooseImage')}
|
||||
accept="image/png,image/jpeg,image/webp,image/gif,image/x-icon,image/vnd.microsoft.icon"
|
||||
onChange={(event) => { void selectFile(event.target.files?.[0]); event.target.value = ''; }} />
|
||||
{value.image ? <div className={cn('flex min-w-0 items-center gap-3 rounded-lg border border-border/60 bg-muted/20 p-3', dragging && 'border-primary bg-primary/5')}>
|
||||
<div className="grid h-12 w-12 shrink-0 place-items-center rounded-md border border-border/40 bg-background p-1.5"><img src={value.image} alt={t('terminal.browser.iconPreview')} className="h-full w-full rounded-[20%] object-contain" /></div>
|
||||
<div className="min-w-0 flex-1"><p className="truncate text-xs font-medium" title={filename}>{filename || t('terminal.browser.savedImage')}</p><Button type="button" variant="link" size="sm" className="h-auto px-0 py-1 text-xs" disabled={busy} onClick={() => fileInput.current?.click()}>{busy ? t('terminal.browser.processingImage') : t('terminal.browser.replaceImage')}</Button></div>
|
||||
<Button type="button" variant="ghost" size="icon" className="h-7 w-7 shrink-0 text-muted-foreground" disabled={busy} aria-label={t('terminal.browser.removeImage')} onClick={() => { onChange({ image: null }); setFilename(''); setError(null); }}><X size={14} /></Button>
|
||||
</div> : <button type="button" disabled={busy} className={cn('flex w-full flex-col items-center gap-2 rounded-lg border border-dashed border-border bg-muted/15 px-4 py-5 text-center transition-colors hover:border-primary/50 hover:bg-muted/40 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring', dragging && 'border-primary bg-primary/5')}
|
||||
onClick={() => fileInput.current?.click()}>
|
||||
<span className="grid h-8 w-8 place-items-center rounded-full bg-secondary/70 text-muted-foreground">{busy ? <Loader2 size={16} className="animate-spin" /> : <Upload size={16} />}</span>
|
||||
<span className="text-xs font-medium">{t(busy ? 'terminal.browser.processingImage' : 'terminal.browser.uploadImage')}</span>
|
||||
<span className="text-[11px] leading-relaxed text-muted-foreground">{t('terminal.browser.dropImage')}</span>
|
||||
</button>}
|
||||
<p className="text-[11px] leading-relaxed text-muted-foreground">{t('terminal.browser.imageHint')}</p>
|
||||
</div>}
|
||||
{error && <p role="alert" className="text-xs leading-relaxed text-destructive">{error}</p>}
|
||||
</div>;
|
||||
}
|
||||
@@ -1,19 +1,17 @@
|
||||
/**
|
||||
* Generate Key Panel - Standard SSH Key generation form
|
||||
*/
|
||||
|
||||
import { Eye, EyeOff } from 'lucide-react';
|
||||
/** Standard and hardware-backed SSH key generation. */
|
||||
import { Eye, EyeOff, Loader2, Usb } from 'lucide-react';
|
||||
import React from 'react';
|
||||
import { useI18n } from '../../application/i18n/I18nProvider';
|
||||
import { cn } from '../../lib/utils';
|
||||
import { KeyType, SSHKey } from '../../types';
|
||||
import { Button } from '../ui/button';
|
||||
import { Input } from '../ui/input';
|
||||
import { Label } from '../ui/label';
|
||||
import { Select, SelectContent, SelectGroup, SelectItem, SelectLabel, SelectSeparator, SelectTrigger, SelectValue } from '../ui/select';
|
||||
import { Switch } from '../ui/switch';
|
||||
|
||||
interface GenerateStandardPanelProps {
|
||||
draftKey: Partial<SSHKey>;
|
||||
setDraftKey: (key: Partial<SSHKey>) => void;
|
||||
draftKey: Partial<SSHKey> & { resident?: boolean; verifyRequired?: boolean };
|
||||
setDraftKey: (key: Partial<SSHKey> & { resident?: boolean; verifyRequired?: boolean }) => void;
|
||||
showPassphrase: boolean;
|
||||
setShowPassphrase: (show: boolean) => void;
|
||||
isGenerating: boolean;
|
||||
@@ -21,119 +19,115 @@ interface GenerateStandardPanelProps {
|
||||
}
|
||||
|
||||
export const GenerateStandardPanel: React.FC<GenerateStandardPanelProps> = ({
|
||||
draftKey,
|
||||
setDraftKey,
|
||||
showPassphrase,
|
||||
setShowPassphrase,
|
||||
isGenerating,
|
||||
onGenerate,
|
||||
draftKey, setDraftKey, showPassphrase, setShowPassphrase, isGenerating, onGenerate,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const isFido = draftKey.type === 'ED25519-SK' || draftKey.type === 'ECDSA-SK';
|
||||
const selectType = (value: string) => {
|
||||
const type = value as KeyType;
|
||||
setDraftKey({ ...draftKey, type, keySize: type === 'RSA' ? 4096 : type === 'ECDSA' ? 256 : undefined });
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="space-y-5" data-section="keychain-generate-form">
|
||||
<div className="space-y-2">
|
||||
<Label>{t('keychain.field.label')}</Label>
|
||||
<Input
|
||||
value={draftKey.label || ''}
|
||||
<Label htmlFor="generate-key-label">{t('keychain.field.label')}</Label>
|
||||
<Input id="generate-key-label" value={draftKey.label || ''}
|
||||
onChange={e => setDraftKey({ ...draftKey, label: e.target.value })}
|
||||
placeholder={t('keychain.generate.labelPlaceholder')}
|
||||
/>
|
||||
placeholder={t('keychain.generate.labelPlaceholder')} disabled={isGenerating} />
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>{t('keychain.generate.keyType')}</Label>
|
||||
<div className="flex gap-2">
|
||||
{(['ED25519', 'ECDSA', 'RSA'] as KeyType[]).map((t) => (
|
||||
<Button
|
||||
key={t}
|
||||
variant={draftKey.type === t ? 'secondary' : 'ghost'}
|
||||
className={cn(
|
||||
"flex-1 h-10",
|
||||
draftKey.type === t && "bg-primary/15 text-primary"
|
||||
)}
|
||||
onClick={() => {
|
||||
// Set default keySize based on type
|
||||
const defaultSize = t === 'ED25519' ? undefined : (t === 'RSA' ? 4096 : 256);
|
||||
setDraftKey({ ...draftKey, type: t, keySize: defaultSize });
|
||||
}}
|
||||
>
|
||||
{t}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
<Label htmlFor="generate-key-type">{t('keychain.generate.keyType')}</Label>
|
||||
<Select value={draftKey.type || 'ED25519'} onValueChange={selectType} disabled={isGenerating}>
|
||||
<SelectTrigger id="generate-key-type"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
<SelectLabel>{t('keychain.generate.standardKeys')}</SelectLabel>
|
||||
{['ED25519', 'ECDSA', 'RSA'].map(type => <SelectItem key={type} value={type}>{type}</SelectItem>)}
|
||||
</SelectGroup>
|
||||
<SelectSeparator />
|
||||
<SelectGroup>
|
||||
<SelectLabel>{t('keychain.generate.securityKeys')}</SelectLabel>
|
||||
<SelectItem value="ED25519-SK">ED25519-SK</SelectItem>
|
||||
<SelectItem value="ECDSA-SK">ECDSA-SK</SelectItem>
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* Key Size selector - only for RSA and ECDSA */}
|
||||
{(draftKey.type === 'RSA' || draftKey.type === 'ECDSA') && (
|
||||
<div className="space-y-2">
|
||||
<Label>{t('keychain.generate.keySize')}</Label>
|
||||
<div className="flex gap-2">
|
||||
{(draftKey.type === 'RSA'
|
||||
? [4096, 2048, 1024]
|
||||
: [256, 384, 521]
|
||||
).map((size) => (
|
||||
<Button
|
||||
key={size}
|
||||
variant={draftKey.keySize === size ? 'secondary' : 'ghost'}
|
||||
className={cn(
|
||||
"flex-1 h-10",
|
||||
draftKey.keySize === size && "bg-primary/15 text-primary"
|
||||
)}
|
||||
onClick={() => setDraftKey({ ...draftKey, keySize: size })}
|
||||
>
|
||||
{draftKey.type === 'RSA' ? `${size} bits` : `P-${size}`}
|
||||
<div className="space-y-2 fido-field-stage" key="key-size">
|
||||
<Label htmlFor="generate-key-size">{t('keychain.generate.keySize')}</Label>
|
||||
<Select value={String(draftKey.keySize || (draftKey.type === 'RSA' ? 4096 : 256))}
|
||||
onValueChange={value => setDraftKey({ ...draftKey, keySize: Number(value) })} disabled={isGenerating}>
|
||||
<SelectTrigger id="generate-key-size"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{(draftKey.type === 'RSA' ? [4096, 2048, 1024] : [256, 384, 521]).map(size => (
|
||||
<SelectItem key={size} value={String(size)}>{draftKey.type === 'RSA' ? `${size} bits` : `P-${size}`}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isFido ? (
|
||||
<div key="hardware" className="fido-field-stage fido-key-options space-y-4">
|
||||
<div className="flex gap-2.5 rounded-lg bg-muted/40 px-3 py-3 text-xs leading-relaxed text-muted-foreground">
|
||||
<Usb size={15} className="mt-0.5 shrink-0" aria-hidden="true" />
|
||||
<p>{t('keychain.generate.fidoHint')}</p>
|
||||
</div>
|
||||
<div className="divide-y divide-border/50 border-y border-border/50">
|
||||
<div className="flex items-center justify-between gap-4 py-3.5">
|
||||
<div className="min-w-0 space-y-1">
|
||||
<Label id="fido-resident-label" className="text-sm font-medium">{t('keychain.generate.resident')}</Label>
|
||||
<p id="fido-resident-description" className="text-xs leading-relaxed text-muted-foreground">{t('keychain.generate.residentDesc')}</p>
|
||||
</div>
|
||||
<Switch id="fido-resident" checked={!!draftKey.resident} disabled={isGenerating}
|
||||
aria-labelledby="fido-resident-label" aria-describedby="fido-resident-description"
|
||||
onCheckedChange={resident => setDraftKey({ ...draftKey, resident })} />
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-4 py-3.5">
|
||||
<div className="min-w-0 space-y-1">
|
||||
<Label id="fido-verify-label" className="text-sm font-medium">{t('keychain.generate.verifyRequired')}</Label>
|
||||
<p id="fido-verify-description" className="text-xs leading-relaxed text-muted-foreground">{t('keychain.generate.verifyRequiredDesc')}</p>
|
||||
</div>
|
||||
<Switch id="fido-verify-required" checked={!!draftKey.verifyRequired} disabled={isGenerating}
|
||||
aria-labelledby="fido-verify-label" aria-describedby="fido-verify-description"
|
||||
onCheckedChange={verifyRequired => setDraftKey({ ...draftKey, verifyRequired })} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div key="software" className="fido-field-stage space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="generate-key-passphrase">{t('terminal.auth.passphrase')}</Label>
|
||||
<div className="relative">
|
||||
<Input id="generate-key-passphrase" type={showPassphrase ? 'text' : 'password'}
|
||||
value={draftKey.passphrase || ''} disabled={isGenerating}
|
||||
onChange={e => setDraftKey({ ...draftKey, passphrase: e.target.value })}
|
||||
placeholder={t('keychain.generate.passphrasePlaceholder')} className="pr-10" />
|
||||
<Button type="button" variant="ghost" size="icon" disabled={isGenerating}
|
||||
aria-label={t(showPassphrase ? 'hostDetails.password.hide' : 'hostDetails.password.show')}
|
||||
className="absolute right-1 top-1/2 h-8 w-8 -translate-y-1/2 text-muted-foreground"
|
||||
onClick={() => setShowPassphrase(!showPassphrase)}>
|
||||
{showPassphrase ? <EyeOff size={15} /> : <Eye size={15} />}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<Label id="generate-save-passphrase">{t('keychain.generate.savePassphrase')}</Label>
|
||||
<Switch checked={!!draftKey.savePassphrase} disabled={isGenerating}
|
||||
aria-labelledby="generate-save-passphrase"
|
||||
onCheckedChange={savePassphrase => setDraftKey({ ...draftKey, savePassphrase })} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>{t('terminal.auth.passphrase')}</Label>
|
||||
<div className="relative">
|
||||
<Input
|
||||
type={showPassphrase ? 'text' : 'password'}
|
||||
value={draftKey.passphrase || ''}
|
||||
onChange={e => setDraftKey({ ...draftKey, passphrase: e.target.value })}
|
||||
placeholder={t('keychain.generate.passphrasePlaceholder')}
|
||||
className="pr-10"
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="absolute right-1 top-1/2 -translate-y-1/2 h-8 w-8"
|
||||
onClick={() => setShowPassphrase(!showPassphrase)}
|
||||
>
|
||||
{showPassphrase ? <EyeOff size={14} /> : <Eye size={14} />}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="savePassphrase"
|
||||
checked={draftKey.savePassphrase || false}
|
||||
onChange={e => setDraftKey({ ...draftKey, savePassphrase: e.target.checked })}
|
||||
className="h-4 w-4 rounded border-border"
|
||||
/>
|
||||
<Label htmlFor="savePassphrase" className="text-sm font-normal cursor-pointer">
|
||||
{t('keychain.generate.savePassphrase')}
|
||||
</Label>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
className="w-full h-11"
|
||||
onClick={onGenerate}
|
||||
disabled={isGenerating || !draftKey.label?.trim()}
|
||||
>
|
||||
{isGenerating ? (
|
||||
<div className="h-4 w-4 border-2 border-primary-foreground/30 border-t-primary-foreground rounded-full animate-spin" />
|
||||
) : (
|
||||
t('keychain.generate.generateSave')
|
||||
)}
|
||||
<Button className="h-10 w-full gap-2" onClick={onGenerate} disabled={isGenerating || !draftKey.label?.trim()}>
|
||||
{isGenerating && <Loader2 className="h-4 w-4 animate-spin" aria-hidden="true" />}
|
||||
{t(isGenerating ? 'keychain.generate.generating' : 'keychain.generate.generateSave')}
|
||||
</Button>
|
||||
</>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,61 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
|
||||
import { SK_ECDSA_NISTP256, SK_SSH_ED25519 } from "../../domain/fidoSsh.ts";
|
||||
import { detectKeyType, resolveImportedKeyType } from "./utils.ts";
|
||||
|
||||
/** Real OpenSSH shape: algorithm only after base64 decode. */
|
||||
function makeSkPrivatePem(algo: string): string {
|
||||
const body = Buffer.from(
|
||||
`openssh-key-v1\0\0\0\0\0none\0\0\0\0\0\0\0\0\0\x01${algo}`,
|
||||
).toString("base64");
|
||||
const pem = `-----BEGIN OPENSSH PRIVATE KEY-----\n${body}\n-----END OPENSSH PRIVATE KEY-----\n`;
|
||||
assert.equal(pem.includes("@openssh.com"), false);
|
||||
return pem;
|
||||
}
|
||||
|
||||
test("resolveImportedKeyType prefers FIDO detection over seeded ED25519 draft", () => {
|
||||
const pem = makeSkPrivatePem(SK_SSH_ED25519);
|
||||
// Simulate openImport seed + paste import (handleImport path).
|
||||
const seededDraftType = "ED25519" as const;
|
||||
const detected = resolveImportedKeyType({ privateKey: pem });
|
||||
assert.equal(detected, "ED25519-SK");
|
||||
// Product rule: material wins; never keep seeded ED25519 when SK is detected.
|
||||
const savedType = detected; // same as fixed handleImport
|
||||
assert.notEqual(savedType, seededDraftType);
|
||||
assert.equal(savedType, "ED25519-SK");
|
||||
});
|
||||
|
||||
test("resolveImportedKeyType distinguishes ECDSA-SK from base64-only PEM", () => {
|
||||
assert.equal(
|
||||
resolveImportedKeyType({ privateKey: makeSkPrivatePem(SK_ECDSA_NISTP256) }),
|
||||
"ECDSA-SK",
|
||||
);
|
||||
});
|
||||
|
||||
test("detectKeyType uses the same material path for file drop", () => {
|
||||
assert.equal(detectKeyType(makeSkPrivatePem(SK_SSH_ED25519)), "ED25519-SK");
|
||||
assert.equal(detectKeyType("-----BEGIN RSA PRIVATE KEY-----\nMII\n-----END RSA PRIVATE KEY-----"), "RSA");
|
||||
});
|
||||
|
||||
test("resolveImportedKeyType keeps soft keys", () => {
|
||||
assert.equal(
|
||||
resolveImportedKeyType({
|
||||
privateKey: "-----BEGIN OPENSSH PRIVATE KEY-----\nAAAA\n-----END OPENSSH PRIVATE KEY-----",
|
||||
}),
|
||||
"ED25519",
|
||||
);
|
||||
});
|
||||
|
||||
test("KeychainManager handleImport uses material type not seeded draft type", () => {
|
||||
const source = readFileSync(join(import.meta.dirname, "../KeychainManager.tsx"), "utf8");
|
||||
// Must resolve from material; must not keep draftKey.type first (openImport seeds ED25519).
|
||||
assert.match(source, /detectSshKeyType/);
|
||||
assert.doesNotMatch(
|
||||
source,
|
||||
/type:\s*\(draftKey\.type as KeyType\)\s*\|\|\s*detectedType/,
|
||||
);
|
||||
assert.match(source, /type:\s*detected.type/);
|
||||
});
|
||||
@@ -4,6 +4,7 @@
|
||||
|
||||
import { BadgeCheck, Key } from 'lucide-react';
|
||||
import React from 'react';
|
||||
import { detectFidoSshKeyType } from '../../domain/fidoSsh';
|
||||
import { logger } from '../../lib/logger';
|
||||
import { detectSshKeyType } from '../../domain/keyTypeDetect';
|
||||
import { KeyType, SSHKey } from '../../types';
|
||||
@@ -25,12 +26,25 @@ export const getKeyTypeDisplay = (key: SSHKey, isMac: boolean): string => {
|
||||
};
|
||||
|
||||
/**
|
||||
* Detect key type from private key content
|
||||
* Resolve vault KeyType from imported key material.
|
||||
*
|
||||
* Always prefer material detection over the import form's seeded default
|
||||
* (`openImport` seeds `type: "ED25519"`). Real OpenSSH sk private PEMs only
|
||||
* expose sk-* algorithms after base64 decode — handled by detectFidoSshKeyType.
|
||||
*/
|
||||
export const detectKeyType = (privateKey: string): KeyType => {
|
||||
return detectSshKeyType(privateKey).type;
|
||||
export const resolveImportedKeyType = (args: {
|
||||
privateKey: string;
|
||||
publicKey?: string;
|
||||
}): KeyType => {
|
||||
return detectFidoSshKeyType(args) || detectSshKeyType(args.privateKey, args.publicKey).type;
|
||||
};
|
||||
|
||||
/**
|
||||
* Detect key type from private key content (file drop / paste helpers).
|
||||
*/
|
||||
export const detectKeyType = (privateKey: string): KeyType =>
|
||||
resolveImportedKeyType({ privateKey });
|
||||
|
||||
/**
|
||||
* Copy text to clipboard
|
||||
*/
|
||||
|
||||
@@ -37,6 +37,7 @@ import {
|
||||
sanitizeCredentialValue,
|
||||
} from "../../../domain/credentials";
|
||||
import { resolveBridgeSshAgentAuth, resolveHostAuth } from "../../../domain/sshAuth";
|
||||
import { requiresFidoSshAgentAuth } from "../../../domain/fidoSsh";
|
||||
import {
|
||||
hostRestrictsExtraSshChannels,
|
||||
resolveHostKeepalive,
|
||||
@@ -111,6 +112,32 @@ const createPluginConnectionRequestId = (): string => {
|
||||
return `plugin-connection-${randomId}`.slice(0, 128);
|
||||
};
|
||||
|
||||
/**
|
||||
* Private key material for the bridge.
|
||||
* Soft keys: PEM/OpenSSH private key for ssh2.
|
||||
* FIDO2 SK: still send the OpenSSH key *handle* so the main process can
|
||||
* ssh-add it into the system agent (signing stays on the hardware).
|
||||
* Reference keys: path-only (no inline material).
|
||||
*/
|
||||
const resolveBridgePrivateKeyMaterial = (
|
||||
key: SSHKey | undefined | null,
|
||||
options?: { forceAgent?: boolean },
|
||||
): string | undefined => {
|
||||
if (!key || key.source === "reference") return undefined;
|
||||
// Soft-key agent mode still omits inline privateKey when the agent will sign.
|
||||
if (
|
||||
options?.forceAgent
|
||||
&& !requiresFidoSshAgentAuth({
|
||||
type: key.type,
|
||||
publicKey: key.publicKey,
|
||||
privateKey: key.privateKey,
|
||||
})
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
return sanitizeCredentialValue(key.privateKey) || undefined;
|
||||
};
|
||||
|
||||
const formatPluginDiagnosticLines = (
|
||||
diagnostics: ReadonlyArray<ProviderValidationIssue> | undefined,
|
||||
): string[] => (diagnostics ?? [])
|
||||
@@ -451,7 +478,9 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
|
||||
authMethod: jumpAuth.authMethod,
|
||||
requiresMfa: !!jumpHost.requiresMfa,
|
||||
password: jumpPassword,
|
||||
privateKey: jumpKey?.source === 'reference' ? undefined : jumpPrivateKey,
|
||||
privateKey: resolveBridgePrivateKeyMaterial(jumpKey, {
|
||||
forceAgent: jumpAgentAuth.useSshAgent === true && !jumpKey?.certificate,
|
||||
}),
|
||||
certificate: jumpKey?.certificate,
|
||||
passphrase: jumpPassphrase,
|
||||
publicKey: jumpKey?.publicKey,
|
||||
@@ -679,10 +708,11 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
|
||||
sftpReuseOptions: sftpReuseOptions?.password || sftpReuseOptions?.username !== effectiveUsername
|
||||
? undefined
|
||||
: sftpReuseOptions,
|
||||
privateKey: attempt.key?.source === 'reference' ? undefined : (sanitizeCredentialValue(attempt.key?.privateKey) || undefined),
|
||||
privateKey: resolveBridgePrivateKeyMaterial(attempt.key),
|
||||
certificate: attempt.key?.certificate,
|
||||
publicKey: attempt.key?.publicKey,
|
||||
keyId: attempt.key?.id,
|
||||
keyLabel: attempt.key?.label,
|
||||
keySource: attempt.key?.source,
|
||||
passphrase: attempt.key
|
||||
? (effectivePassphrase || sanitizeCredentialValue(attempt.key.passphrase))
|
||||
@@ -855,7 +885,10 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
|
||||
ctx.setIsConnectionAwaitingUserInput?.(false);
|
||||
ctx.setIsConnectionPastTcpDial?.(false);
|
||||
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
const rawMessage = err instanceof Error ? err.message : String(err);
|
||||
const message = rawMessage.includes("Security key authentication cancelled")
|
||||
? tr("fido.prompt.cancelled", "Security key authentication cancelled.")
|
||||
: rawMessage;
|
||||
const authError = isAuthError(err);
|
||||
|
||||
if (isJumpHostAuthError(err, message)) {
|
||||
@@ -1269,7 +1302,9 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
|
||||
authMethod,
|
||||
requiresMfa: !!ctx.host.requiresMfa,
|
||||
password: effectivePassword,
|
||||
privateKey: (usesSystemAgent && !key?.certificate) || key?.source === 'reference' ? undefined : (sanitizeCredentialValue(key?.privateKey) || undefined),
|
||||
privateKey: resolveBridgePrivateKeyMaterial(key, {
|
||||
forceAgent: usesSystemAgent && !key?.certificate,
|
||||
}),
|
||||
certificate: key?.certificate,
|
||||
keyId: key?.id,
|
||||
passphrase: key && (!usesSystemAgent || Boolean(key.certificate))
|
||||
@@ -1563,7 +1598,9 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
|
||||
username: jumpAuth.username || "root",
|
||||
authMethod: jumpAuth.authMethod,
|
||||
password: jumpPassword,
|
||||
privateKey: (jumpAgentAuth.useSshAgent && !jumpKey?.certificate) || jumpKey?.source === 'reference' ? undefined : jumpPrivateKey,
|
||||
privateKey: resolveBridgePrivateKeyMaterial(jumpKey, {
|
||||
forceAgent: jumpAgentAuth.useSshAgent === true && !jumpKey?.certificate,
|
||||
}),
|
||||
certificate: jumpKey?.certificate,
|
||||
passphrase: jumpAgentAuth.useSshAgent && !jumpKey?.certificate ? undefined : jumpPassphrase,
|
||||
keyId: jumpAuth.keyId,
|
||||
@@ -1602,7 +1639,9 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
|
||||
hostId: ctx.host.id,
|
||||
username: resolvedAuth.username || "root",
|
||||
password: effectivePassword,
|
||||
privateKey: (usesSystemAgent && !key?.certificate) || key?.source === 'reference' ? undefined : (sanitizeCredentialValue(key?.privateKey) || undefined),
|
||||
privateKey: resolveBridgePrivateKeyMaterial(key, {
|
||||
forceAgent: usesSystemAgent && !key?.certificate,
|
||||
}),
|
||||
certificate: key?.certificate,
|
||||
keyId: key?.id,
|
||||
passphrase: key && (!usesSystemAgent || Boolean(key.certificate))
|
||||
|
||||
@@ -145,10 +145,16 @@ export const AsidePanelHeader: React.FC<AsidePanelHeaderProps> = ({
|
||||
};
|
||||
|
||||
// Content Component (wraps children with scroll)
|
||||
export const AsidePanelContent: React.FC<{ children: ReactNode; className?: string }> = ({
|
||||
export const AsidePanelContent: React.FC<{ children: ReactNode; className?: string; scrollMode?: "custom" | "native" }> = ({
|
||||
children,
|
||||
className,
|
||||
scrollMode = "custom",
|
||||
}) => {
|
||||
if (scrollMode === "native") return (
|
||||
<div data-aside-scroll-viewport className={cn("flex-1 min-h-0 min-w-0 overflow-y-auto overscroll-y-contain host-tree-notes-scroll", className)}>
|
||||
<div className="p-4 space-y-4 min-w-0 overflow-hidden">{children}</div>
|
||||
</div>
|
||||
);
|
||||
return (
|
||||
<ScrollArea className={cn("flex-1 min-w-0 [&>[data-radix-scroll-area-viewport]>div]:!block [&>[data-radix-scroll-area-viewport]>div]:!min-w-0", className)}>
|
||||
<div className="p-4 space-y-4 min-w-0 overflow-hidden">
|
||||
|
||||
@@ -0,0 +1,275 @@
|
||||
import React, { useCallback, useLayoutEffect, useRef, useState } from "react";
|
||||
import { defaultRangeExtractor, useVirtualizer } from "@tanstack/react-virtual";
|
||||
import {
|
||||
MONITOR_CARD_HEIGHT,
|
||||
MONITOR_COLUMNS,
|
||||
monitorGridColumns,
|
||||
type MonitorColumn,
|
||||
} from "../../domain/monitorTableLayout";
|
||||
import { useMonitorTableColumns } from "../../application/state/useMonitorTableColumns";
|
||||
import { useI18n } from "../../application/i18n/I18nProvider";
|
||||
|
||||
import {
|
||||
ContextMenu,
|
||||
ContextMenuTrigger,
|
||||
ContextMenuContent,
|
||||
ContextMenuCheckboxItem,
|
||||
ContextMenuLabel,
|
||||
} from "../ui/context-menu";
|
||||
|
||||
export function MonitorHostCollection<T extends { id: string }>({
|
||||
items,
|
||||
view,
|
||||
layoutKey,
|
||||
pinnedId,
|
||||
renderCard,
|
||||
renderCells,
|
||||
rowProps,
|
||||
renderMenu,
|
||||
}: {
|
||||
items: T[];
|
||||
view: "list" | "charts";
|
||||
layoutKey: string;
|
||||
pinnedId?: string;
|
||||
renderCard: (item: T) => React.ReactNode;
|
||||
renderCells: (item: T) => Record<MonitorColumn, React.ReactNode>;
|
||||
rowProps: (item: T) => React.HTMLAttributes<HTMLTableRowElement>;
|
||||
renderMenu: (item: T) => React.ReactNode;
|
||||
}) {
|
||||
const { t } = useI18n();
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
const [width, setWidth] = useState(1248);
|
||||
const { widths, resize, startResize, hidden, setVisible } =
|
||||
useMonitorTableColumns();
|
||||
const visibleColumns = MONITOR_COLUMNS.filter(
|
||||
({ key }) => !hidden.includes(key),
|
||||
);
|
||||
const columns =
|
||||
view === "charts" ? monitorGridColumns(width, items.length) : 1;
|
||||
const scrollMargin = view === "charts" ? 16 : 56;
|
||||
const pinnedIndex = pinnedId
|
||||
? items.findIndex((item) => item.id === pinnedId)
|
||||
: -1;
|
||||
const getItemKey = useCallback(
|
||||
(index: number) => items[index * columns]?.id ?? index,
|
||||
[items, columns],
|
||||
);
|
||||
const virtualizer = useVirtualizer<HTMLDivElement, HTMLElement>({
|
||||
count: Math.ceil(items.length / columns),
|
||||
getScrollElement: () => scrollRef.current,
|
||||
estimateSize: () => (view === "charts" ? MONITOR_CARD_HEIGHT : 76),
|
||||
gap: view === "charts" ? 16 : 8,
|
||||
overscan: view === "charts" ? 2 : 5,
|
||||
getItemKey,
|
||||
scrollMargin,
|
||||
initialRect: { width: 1280, height: 800 },
|
||||
rangeExtractor: useCallback(
|
||||
(range) => {
|
||||
const visible = defaultRangeExtractor(range);
|
||||
const pinned = Math.floor(pinnedIndex / columns);
|
||||
return pinnedIndex < 0 || visible.includes(pinned)
|
||||
? visible
|
||||
: [...visible, pinned].sort((a, b) => a - b);
|
||||
},
|
||||
[pinnedIndex, columns],
|
||||
),
|
||||
});
|
||||
useLayoutEffect(() => {
|
||||
const element = scrollRef.current;
|
||||
if (!element) return;
|
||||
const measure = () => setWidth(Math.max(0, element.clientWidth));
|
||||
measure();
|
||||
const observer = new ResizeObserver(measure);
|
||||
observer.observe(element);
|
||||
return () => observer.disconnect();
|
||||
}, []);
|
||||
useLayoutEffect(() => {
|
||||
virtualizer.measure();
|
||||
}, [columns, view, hidden, virtualizer]);
|
||||
useLayoutEffect(() => {
|
||||
if (scrollRef.current) scrollRef.current.scrollTop = 0;
|
||||
virtualizer.scrollToOffset(0);
|
||||
}, [layoutKey, virtualizer]);
|
||||
const template = visibleColumns
|
||||
.map((column) => `${widths[column.key]}px`)
|
||||
.join(" ");
|
||||
const tableWidth = visibleColumns.reduce(
|
||||
(sum, column) => sum + widths[column.key],
|
||||
0,
|
||||
);
|
||||
return (
|
||||
<div className="min-h-0 min-w-0 flex-1 px-4">
|
||||
<div
|
||||
ref={scrollRef}
|
||||
className="h-full min-w-0 overflow-auto"
|
||||
data-section="vault-monitor-list"
|
||||
data-monitor-virtual={view}
|
||||
>
|
||||
<div className="py-4">
|
||||
{view === "charts" ? (
|
||||
<div
|
||||
className="relative"
|
||||
style={{ height: virtualizer.getTotalSize() }}
|
||||
>
|
||||
{virtualizer.getVirtualItems().map((row) => (
|
||||
<div
|
||||
key={row.key}
|
||||
data-monitor-virtual-row={row.index}
|
||||
className="absolute left-0 top-0 grid w-full gap-4"
|
||||
style={{
|
||||
height: MONITOR_CARD_HEIGHT,
|
||||
gridTemplateColumns: `repeat(${columns},minmax(0,1fr))`,
|
||||
transform: `translateY(${row.start - scrollMargin}px)`,
|
||||
}}
|
||||
>
|
||||
{items
|
||||
.slice(row.index * columns, (row.index + 1) * columns)
|
||||
.map((item) => (
|
||||
<ContextMenu key={item.id}>
|
||||
<ContextMenuTrigger asChild>
|
||||
{renderCard(item)}
|
||||
</ContextMenuTrigger>
|
||||
<ContextMenuContent>
|
||||
{renderMenu(item)}
|
||||
</ContextMenuContent>
|
||||
</ContextMenu>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<table
|
||||
className="block text-xs"
|
||||
style={{ width: tableWidth }}
|
||||
aria-rowcount={items.length + 1}
|
||||
>
|
||||
<ContextMenu>
|
||||
<ContextMenuTrigger asChild>
|
||||
<thead className="sticky top-0 z-20 block bg-background text-muted-foreground">
|
||||
<tr
|
||||
className="grid h-10"
|
||||
style={{ gridTemplateColumns: template }}
|
||||
>
|
||||
{visibleColumns.map((column) => (
|
||||
<th
|
||||
key={column.key}
|
||||
data-monitor-column={column.key}
|
||||
className={`relative flex min-w-0 items-center px-3 text-left text-[11px] font-medium${column.key === "terminal" ? " sticky right-0 z-10 bg-background" : ""}`}
|
||||
>
|
||||
<span
|
||||
className="truncate"
|
||||
title={t(`monitor.${column.key}`)}
|
||||
>
|
||||
{column.key === "terminal" ? (
|
||||
<span className="sr-only">
|
||||
{t("monitor.openTerminal")}
|
||||
</span>
|
||||
) : (
|
||||
t(`monitor.${column.key}`)
|
||||
)}
|
||||
</span>
|
||||
<div
|
||||
role="separator"
|
||||
aria-orientation="vertical"
|
||||
aria-label={t("monitor.resizeColumn", {
|
||||
column: t(`monitor.${column.key}`),
|
||||
})}
|
||||
aria-valuenow={widths[column.key]}
|
||||
aria-valuemin={column.min}
|
||||
aria-valuemax={
|
||||
column.key === "terminal" ? 200 : 640
|
||||
}
|
||||
tabIndex={0}
|
||||
className="absolute inset-y-1 right-0 w-2 cursor-col-resize touch-none outline-none after:absolute after:inset-y-1 after:right-0.5 after:w-px hover:after:bg-primary focus-visible:after:bg-primary"
|
||||
onPointerDown={(event) =>
|
||||
startResize(column.key, event)
|
||||
}
|
||||
onDoubleClick={() =>
|
||||
resize(column.key, column.width)
|
||||
}
|
||||
onKeyDown={(event) => {
|
||||
if (
|
||||
event.key === "ArrowLeft" ||
|
||||
event.key === "ArrowRight"
|
||||
) {
|
||||
event.preventDefault();
|
||||
resize(
|
||||
column.key,
|
||||
widths[column.key] +
|
||||
(event.key === "ArrowRight" ? 1 : -1) *
|
||||
(event.shiftKey ? 32 : 8),
|
||||
);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
</ContextMenuTrigger>
|
||||
<ContextMenuContent data-monitor-column-menu>
|
||||
<ContextMenuLabel>
|
||||
{t("monitor.visibleColumns")}
|
||||
</ContextMenuLabel>
|
||||
{MONITOR_COLUMNS.map(({ key }) => (
|
||||
<ContextMenuCheckboxItem
|
||||
key={key}
|
||||
data-monitor-toggle-column={key}
|
||||
checked={!hidden.includes(key)}
|
||||
disabled={key === "host" || key === "terminal"}
|
||||
onSelect={(event) => event.preventDefault()}
|
||||
onCheckedChange={(checked) =>
|
||||
setVisible(key, checked === true)
|
||||
}
|
||||
>
|
||||
{t(`monitor.${key}`)}
|
||||
</ContextMenuCheckboxItem>
|
||||
))}
|
||||
</ContextMenuContent>
|
||||
</ContextMenu>
|
||||
<tbody
|
||||
className="relative block"
|
||||
style={{ height: virtualizer.getTotalSize() }}
|
||||
>
|
||||
{virtualizer.getVirtualItems().map((row) => {
|
||||
const cells = renderCells(items[row.index]);
|
||||
return (
|
||||
<ContextMenu key={row.key}>
|
||||
<ContextMenuTrigger asChild>
|
||||
<tr
|
||||
{...rowProps(items[row.index])}
|
||||
ref={virtualizer.measureElement}
|
||||
data-index={row.index}
|
||||
aria-rowindex={row.index + 2}
|
||||
style={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: template,
|
||||
minHeight: 72,
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: tableWidth,
|
||||
transform: `translateY(${row.start - scrollMargin}px)`,
|
||||
}}
|
||||
>
|
||||
{visibleColumns.map(({ key }) => (
|
||||
<React.Fragment key={key}>
|
||||
{cells[key]}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</tr>
|
||||
</ContextMenuTrigger>
|
||||
<ContextMenuContent>
|
||||
{renderMenu(items[row.index])}
|
||||
</ContextMenuContent>
|
||||
</ContextMenu>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
import React from "react";
|
||||
import { useI18n } from "../../application/i18n/I18nProvider";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "../ui/tooltip";
|
||||
import { Badge } from "../ui/badge";
|
||||
|
||||
export function MonitorHostLatency({
|
||||
value,
|
||||
responseValue,
|
||||
}: {
|
||||
value?: number | null;
|
||||
responseValue?: number | null;
|
||||
}) {
|
||||
const { t } = useI18n();
|
||||
const isValid = (n: number | null | undefined): n is number =>
|
||||
typeof n === "number" && Number.isFinite(n) && n >= 0;
|
||||
const usesResponse = !isValid(value) && isValid(responseValue);
|
||||
const latency = usesResponse ? responseValue : value;
|
||||
const known = isValid(latency);
|
||||
const bars = !known ? 0 : latency <= 100 ? 4 : latency <= 300 ? 2 : 1;
|
||||
const tone = !known
|
||||
? "text-muted-foreground"
|
||||
: bars === 4
|
||||
? "text-emerald-600 dark:text-emerald-400"
|
||||
: bars === 2
|
||||
? "text-amber-500 dark:text-amber-400"
|
||||
: "text-red-600 dark:text-red-400";
|
||||
const label = `${t(usesResponse ? "monitor.responseLatency" : "monitor.latency")}: ${known ? `${Number(latency.toFixed(1))} ms` : "--"}`;
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span
|
||||
role="img"
|
||||
aria-label={label}
|
||||
className={`inline-flex h-5 shrink-0 items-center ${tone}`}
|
||||
data-monitor-latency
|
||||
data-monitor-signal-bars={bars}
|
||||
>
|
||||
<svg width="18" height="14" viewBox="0 0 18 14" aria-hidden="true">
|
||||
{[4, 7, 10, 13].map((height, index) => (
|
||||
<rect
|
||||
key={height}
|
||||
x={1 + index * 4}
|
||||
y={14 - height}
|
||||
width="3"
|
||||
height={height}
|
||||
rx="0.8"
|
||||
fill="currentColor"
|
||||
opacity={index < bars ? 1 : 0.18}
|
||||
/>
|
||||
))}
|
||||
</svg>
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent className={tooltipClass}>{label}</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
const tooltipClass =
|
||||
"max-w-sm whitespace-pre-wrap break-words bg-popover text-popover-foreground shadow-md ring-1 ring-border/40";
|
||||
export function MonitorHostTags({ tags = [] }: { tags?: string[] }) {
|
||||
if (!tags.length)
|
||||
return (
|
||||
<span className="inline-flex h-5 items-center text-[10px] text-muted-foreground">
|
||||
--
|
||||
</span>
|
||||
);
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span
|
||||
className="flex h-5 min-w-0 max-w-full items-center gap-1 overflow-hidden"
|
||||
data-monitor-tags
|
||||
>
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="inline-block max-w-20 truncate border-0 px-1.5 py-0 text-[10px] font-normal leading-5"
|
||||
>
|
||||
{tags[0]}
|
||||
</Badge>
|
||||
{tags.length > 1 && (
|
||||
<span className="shrink-0 text-[10px] text-muted-foreground">
|
||||
+{tags.length - 1}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent className={tooltipClass}>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{tags.map((tag) => (
|
||||
<Badge
|
||||
key={tag}
|
||||
variant="secondary"
|
||||
className="max-w-full break-all text-[10px] font-normal"
|
||||
>
|
||||
{tag}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
export function MonitorHostNotes({
|
||||
notes,
|
||||
focusable = false,
|
||||
}: {
|
||||
notes?: string;
|
||||
focusable?: boolean;
|
||||
}) {
|
||||
if (!notes?.trim())
|
||||
return <span className="text-[10px] text-muted-foreground">--</span>;
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span
|
||||
data-monitor-notes
|
||||
tabIndex={focusable ? 0 : undefined}
|
||||
className="block min-w-0 truncate text-[10px] text-muted-foreground outline-none focus-visible:ring-1 focus-visible:ring-primary"
|
||||
>
|
||||
{notes}
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent className={`${tooltipClass} max-h-64 overflow-y-auto`}>
|
||||
{notes}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
@@ -10,6 +10,26 @@ import type { Host } from "../../domain/models";
|
||||
import type { MonitorPoint } from "../../domain/monitorRuntime";
|
||||
import { MonitorPanels, MonitorTrend } from "./MonitorPanels";
|
||||
import { MonitorDiskUsage } from "./MonitorDiskUsage";
|
||||
import { MonitorHostLatency } from "./MonitorHostMetadata";
|
||||
import { TooltipProvider } from "../ui/tooltip";
|
||||
|
||||
test("latency signals use the stats response when transport ping is unavailable and prefer valid ping readings", () => {
|
||||
for (const [ping, response, bars, label] of [
|
||||
[null, 145, "2", "Response latency (includes remote processing): 145 ms"],
|
||||
[0, 500, "4", "Latency: 0 ms"],
|
||||
[NaN, 450, "1", "Response latency (includes remote processing): 450 ms"],
|
||||
[null, null, "0", "Latency: --"],
|
||||
] as const) {
|
||||
const document = new JSDOM(renderToStaticMarkup(
|
||||
<I18nProvider locale="en"><TooltipProvider>
|
||||
<MonitorHostLatency value={ping} responseValue={response} />
|
||||
</TooltipProvider></I18nProvider>,
|
||||
)).window.document;
|
||||
const signal = document.querySelector("[data-monitor-latency]")!;
|
||||
assert.equal(signal.getAttribute("data-monitor-signal-bars"), bars);
|
||||
assert.equal(signal.getAttribute("aria-label"), label);
|
||||
}
|
||||
});
|
||||
|
||||
const now = 1_800_000_000_000;
|
||||
const point = (ago: number, cpu: number | null): MonitorPoint => ({
|
||||
@@ -161,7 +181,7 @@ for (const view of ["list", "charts"] as const) {
|
||||
assert.equal(document.querySelector("header .lucide-gauge"), null);
|
||||
assert.equal(document.querySelector(".lucide-activity"), null);
|
||||
if (view === "list") {
|
||||
const network = document.querySelector("tbody tr")!.children[5];
|
||||
const network = document.querySelector("[data-monitor-cell=network]")!;
|
||||
assert.deepEqual([...network.querySelectorAll(".sr-only")].map((e) => e.textContent), ["Download: ", "Upload: "]);
|
||||
}
|
||||
});
|
||||
|
||||
+370
-261
@@ -6,6 +6,7 @@ import {
|
||||
Copy,
|
||||
Cpu,
|
||||
Gauge,
|
||||
Gpu,
|
||||
HardDrive,
|
||||
LayoutGrid,
|
||||
List,
|
||||
@@ -38,6 +39,7 @@ import type { Host } from "../../domain/models";
|
||||
import { buildMonitorTrendGeometry, findMonitorChartPoint } from "../../domain/monitorChart";
|
||||
import { cn } from "../../lib/utils";
|
||||
import { Button } from "../ui/button";
|
||||
import { ContextMenuItem } from "../ui/context-menu";
|
||||
import { Input } from "../ui/input";
|
||||
import { Label } from "../ui/label";
|
||||
import { Textarea } from "../ui/textarea";
|
||||
@@ -70,6 +72,9 @@ import { ResourceBar } from "../systemManager/ResourceBar";
|
||||
import { MonitorDiskUsage } from "./MonitorDiskUsage";
|
||||
import { VaultHeaderSearch, VaultPageHeader, vaultHeaderIconButtonClass, vaultHeaderSecondaryButtonClass } from "./VaultPageHeader";
|
||||
import { toast } from "../ui/toast";
|
||||
import { TooltipProvider } from "../ui/tooltip";
|
||||
import { MonitorHostCollection } from "./MonitorHostCollection";
|
||||
import { MonitorHostLatency, MonitorHostNotes, MonitorHostTags } from "./MonitorHostMetadata";
|
||||
|
||||
const emptyMonitorPoints: MonitorPoint[] = [];
|
||||
const percent = (n: number | null | undefined) =>
|
||||
@@ -214,22 +219,15 @@ export function MonitorPanels({
|
||||
} | null>(null);
|
||||
const [selecting, setSelecting] = useState(false);
|
||||
const [detail, setDetail] = useState<string | null>(null);
|
||||
const [recovery, setRecovery] = useState<{ hostId: string; username: string; password: string } | null>(null);
|
||||
const [deleting, setDeleting] = useState<MonitorPanel | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [login, setLogin] = useState({
|
||||
hostId: "",
|
||||
username: "",
|
||||
password: "",
|
||||
});
|
||||
useEffect(
|
||||
() => setLogin({ hostId: "", username: "", password: "" }),
|
||||
[detail],
|
||||
);
|
||||
const panel = panels.find((p) => p.id === selected) ?? panels[0];
|
||||
const running = !!panel && runtime.running.includes(panel.id);
|
||||
const startEdit = (base: MonitorPanel | null) => {
|
||||
setDetail(null);
|
||||
setRecovery(null);
|
||||
setSelecting(false);
|
||||
setError(null);
|
||||
setEditor({
|
||||
@@ -305,28 +303,39 @@ export function MonitorPanels({
|
||||
toast.error(t("monitor.saveFailed"));
|
||||
}
|
||||
};
|
||||
const hostById = useMemo(
|
||||
() => new Map(hosts.map((host) => [host.id, host])),
|
||||
[hosts],
|
||||
);
|
||||
const orderedHostIds = useMemo(() => {
|
||||
if (!panel) return [];
|
||||
return panel.sort === "manual" ? panel.hostIds : [...panel.hostIds].sort((a, b) =>
|
||||
(hosts.find((host) => host.id === a)?.label ?? a).localeCompare(hosts.find((host) => host.id === b)?.label ?? b),
|
||||
);
|
||||
}, [panel, hosts]);
|
||||
return panel.sort === "manual"
|
||||
? panel.hostIds
|
||||
: [...panel.hostIds].sort((a, b) =>
|
||||
(hostById.get(a)?.label ?? a).localeCompare(
|
||||
hostById.get(b)?.label ?? b,
|
||||
),
|
||||
);
|
||||
}, [panel, hostById]);
|
||||
const rows = useMemo(() => {
|
||||
if (!panel) return [];
|
||||
const q = search.trim().toLocaleLowerCase();
|
||||
const rows = orderedHostIds.map((id) => ({
|
||||
id,
|
||||
host: hosts.find((h) => h.id === id),
|
||||
host: hostById.get(id),
|
||||
state: monitorStateSince(runtime.hosts[id], runtime.startedAt[panel.id]),
|
||||
}));
|
||||
return rows.filter(
|
||||
({ host }) => !q ||
|
||||
`${host?.label ?? ""} ${host?.hostname ?? ""} ${host?.group ?? ""}`
|
||||
({ host }) =>
|
||||
!q ||
|
||||
`${host?.label ?? ""} ${host?.hostname ?? ""} ${host?.group ?? ""} ${host?.tags.join(" ") ?? ""} ${host?.notes ?? ""}`
|
||||
.toLocaleLowerCase()
|
||||
.includes(q),
|
||||
);
|
||||
}, [panel, orderedHostIds, hosts, runtime, search]);
|
||||
const dragProps = (hostId: string): React.HTMLAttributes<HTMLElement> & { "data-monitor-host": string } => ({
|
||||
}, [panel, orderedHostIds, hostById, runtime, search]);
|
||||
const dragProps = (
|
||||
hostId: string,
|
||||
): React.HTMLAttributes<HTMLElement> & { "data-monitor-host": string } => ({
|
||||
draggable: true,
|
||||
"data-monitor-host": hostId,
|
||||
onDragStart: (event) => {
|
||||
@@ -336,7 +345,12 @@ export function MonitorPanels({
|
||||
setDragging({ panelId: panel.id, hostId });
|
||||
},
|
||||
onDragOver: (event) => {
|
||||
if (!panel || dragging?.panelId !== panel.id || dragging.hostId === hostId) return;
|
||||
if (
|
||||
!panel ||
|
||||
dragging?.panelId !== panel.id ||
|
||||
dragging.hostId === hostId
|
||||
)
|
||||
return;
|
||||
event.preventDefault();
|
||||
event.dataTransfer.dropEffect = "move";
|
||||
setDropTarget(hostId);
|
||||
@@ -344,10 +358,11 @@ export function MonitorPanels({
|
||||
onDrop: (event) => {
|
||||
if (!panel || dragging?.panelId !== panel.id) return;
|
||||
event.preventDefault();
|
||||
if (dragging.hostId !== hostId) void patchPanel({
|
||||
hostIds: reorderMonitorHosts(orderedHostIds, dragging.hostId, hostId),
|
||||
sort: "manual",
|
||||
});
|
||||
if (dragging.hostId !== hostId)
|
||||
void patchPanel({
|
||||
hostIds: reorderMonitorHosts(orderedHostIds, dragging.hostId, hostId),
|
||||
sort: "manual",
|
||||
});
|
||||
setDragging(null);
|
||||
setDropTarget(null);
|
||||
},
|
||||
@@ -362,18 +377,24 @@ export function MonitorPanels({
|
||||
: !running
|
||||
? "stopped"
|
||||
: state?.status === "live" &&
|
||||
Date.now() - state.at > Math.max(5000, (panel?.interval ?? DEFAULT_MONITOR_INTERVAL) * 2500)
|
||||
Date.now() - state.at >
|
||||
Math.max(
|
||||
5000,
|
||||
(panel?.interval ?? DEFAULT_MONITOR_INTERVAL) * 2500,
|
||||
)
|
||||
? "stale"
|
||||
: (state?.status ?? "queued");
|
||||
const stateBadge = (host: Host | undefined, state?: MonitorHostState) => (
|
||||
<span
|
||||
className={cn(
|
||||
"inline-flex items-center gap-1.5 whitespace-nowrap text-[10px] shrink-0 font-medium",
|
||||
"inline-flex min-w-0 max-w-full items-center gap-1.5 whitespace-nowrap text-[10px] shrink-0 font-medium",
|
||||
status(host, state) === "live"
|
||||
? "text-emerald-700 dark:text-emerald-400"
|
||||
: ["error", "disconnected", "missing"].includes(status(host, state))
|
||||
? "text-destructive"
|
||||
: ["login", "stale", "unsupported", "limit"].includes(status(host, state))
|
||||
: ["login", "stale", "unsupported", "limit"].includes(
|
||||
status(host, state),
|
||||
)
|
||||
? "text-amber-700 dark:text-amber-400"
|
||||
: "text-muted-foreground",
|
||||
)}
|
||||
@@ -382,7 +403,9 @@ export function MonitorPanels({
|
||||
aria-hidden="true"
|
||||
className="h-1.5 w-1.5 rounded-full bg-current"
|
||||
/>
|
||||
{t(`monitor.status.${status(host, state)}`)}
|
||||
<span className="truncate">
|
||||
{t(`monitor.status.${status(host, state)}`)}
|
||||
</span>
|
||||
</span>
|
||||
);
|
||||
const renderMetrics = (
|
||||
@@ -445,6 +468,10 @@ export function MonitorPanels({
|
||||
};
|
||||
const hostDetail = hosts.find((h) => h.id === detail);
|
||||
const detailState = detail && panel ? monitorStateSince(runtime.hosts[detail], runtime.startedAt[panel.id]) : undefined;
|
||||
const detailAvailable = status(hostDetail, detailState) === "live";
|
||||
useEffect(() => {
|
||||
if (detail && !detailAvailable) setDetail(null);
|
||||
}, [detail, detailAvailable]);
|
||||
return (
|
||||
<section
|
||||
className="relative flex min-h-0 min-w-0 flex-1 overflow-hidden"
|
||||
@@ -647,35 +674,76 @@ export function MonitorPanels({
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="min-h-0 min-w-0 flex-1 overflow-auto" data-section="vault-monitor-list">
|
||||
<div className="p-4">
|
||||
{!rows.length ? (
|
||||
<p className="p-8 text-center text-sm text-muted-foreground">
|
||||
{t("monitor.noMatches")}
|
||||
</p>
|
||||
) : panel.view === "charts" ? (
|
||||
<div className="grid auto-rows-fr grid-cols-[repeat(auto-fit,minmax(min(100%,280px),1fr))] gap-4">
|
||||
{rows.map(({ id, host, state }) => (
|
||||
{!rows.length ? (
|
||||
<div className="flex-1 overflow-auto p-12 text-center text-sm text-muted-foreground">
|
||||
{t("monitor.noMatches")}
|
||||
</div>
|
||||
) : (
|
||||
<TooltipProvider delayDuration={180}>
|
||||
<MonitorHostCollection
|
||||
items={rows}
|
||||
view={panel.view}
|
||||
layoutKey={`${panel.id}:${panel.view}:${search}`}
|
||||
pinnedId={
|
||||
dragging?.panelId === panel.id ? dragging.hostId : undefined
|
||||
}
|
||||
renderMenu={({ id, host, state }) => (
|
||||
<>
|
||||
<ContextMenuItem disabled={!host} onSelect={() => host && onConnect(host)}>
|
||||
{t("monitor.openTerminal")}
|
||||
</ContextMenuItem>
|
||||
{host && running && ["login", "error", "disconnected"].includes(status(host, state)) && (
|
||||
<ContextMenuItem data-monitor-retry={id} onSelect={() => setRecovery({hostId: id, username: host.username ?? "", password: ""})}>
|
||||
{t("monitor.retry")}
|
||||
</ContextMenuItem>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
rowProps={({ id, host, state }) => ({
|
||||
...dragProps(id),
|
||||
className: cn(
|
||||
"group cursor-default [&>td]:bg-card/70 hover:[&>td]:bg-card [&>td:first-child]:rounded-l-xl [&>td]:min-w-0 [&>td]:px-3 [&>td]:py-3 [&>td]:flex [&>td]:items-center",
|
||||
dragging?.panelId === panel.id &&
|
||||
dragging.hostId === id &&
|
||||
"cursor-grabbing opacity-40",
|
||||
dropTarget === id && "[&>td]:bg-accent",
|
||||
status(host, state) !== "live" && "text-muted-foreground opacity-50",
|
||||
),
|
||||
})}
|
||||
renderCard={({ id, host, state }) => (
|
||||
<article
|
||||
{...dragProps(id)}
|
||||
className={cn(
|
||||
"relative h-full min-w-0 overflow-hidden rounded-xl bg-card/70 shadow-sm shadow-black/[0.02] transition-colors hover:bg-card",
|
||||
dragging?.panelId === panel.id &&
|
||||
dragging.hostId === id &&
|
||||
"cursor-grabbing opacity-40",
|
||||
dropTarget === id && "bg-accent",
|
||||
status(host, state) !== "live" && "opacity-50",
|
||||
)}
|
||||
>
|
||||
<button
|
||||
key={id}
|
||||
{...dragProps(id)}
|
||||
className={cn(
|
||||
"flex h-full min-w-0 cursor-default flex-col overflow-hidden rounded-xl bg-card/70 p-4 text-left shadow-sm shadow-black/[0.02] transition-colors hover:bg-card focus-visible:outline focus-visible:outline-primary",
|
||||
dragging?.panelId === panel.id && dragging.hostId === id && "cursor-grabbing opacity-40",
|
||||
dropTarget === id && "bg-accent",
|
||||
status(host, state) !== "live" &&
|
||||
"text-muted-foreground",
|
||||
)}
|
||||
type="button"
|
||||
className="flex h-full w-full min-w-0 cursor-default flex-col p-4 text-left outline-none focus-visible:ring-1 focus-visible:ring-inset focus-visible:ring-primary"
|
||||
aria-disabled={status(host, state) !== "live"}
|
||||
onClick={() => {
|
||||
if (status(host, state) !== "live") return;
|
||||
setEditor(null);
|
||||
setDetail(id);
|
||||
}}
|
||||
>
|
||||
<div className="mb-5 flex items-center gap-3">
|
||||
<div className="mb-2 flex w-full min-w-0 items-center gap-3 pr-7">
|
||||
{host ? (
|
||||
<DistroAvatar host={host} fallback={host.label} size="log" />
|
||||
<DistroAvatar
|
||||
host={host}
|
||||
fallback={host.label}
|
||||
size="log"
|
||||
/>
|
||||
) : (
|
||||
<Server size={24} className="shrink-0 text-muted-foreground" />
|
||||
<Server
|
||||
size={24}
|
||||
className="shrink-0 text-muted-foreground"
|
||||
/>
|
||||
)}
|
||||
<div className="min-w-0 flex-1">
|
||||
<h3 className="truncate text-sm font-semibold text-foreground">
|
||||
@@ -685,20 +753,39 @@ export function MonitorPanels({
|
||||
{host?.hostname ?? id}
|
||||
</p>
|
||||
</div>
|
||||
{stateBadge(host, state)}
|
||||
</div>
|
||||
<div className={cn(status(host, state) !== "live" && "opacity-60")}>
|
||||
<div className="mb-2 flex h-5 w-full min-w-0 items-center gap-2">
|
||||
<MonitorHostLatency value={state?.stats?.latencyMs} responseValue={state?.stats?.responseLatencyMs} />
|
||||
<div className="min-w-0 flex-1">
|
||||
<MonitorHostTags tags={host?.tags} />
|
||||
</div>
|
||||
<div className="min-w-0 max-w-[40%]">
|
||||
{stateBadge(host, state)}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className={cn(
|
||||
"w-full min-w-0",
|
||||
status(host, state) !== "live" && "opacity-60",
|
||||
)}
|
||||
>
|
||||
{renderMetrics(state)}
|
||||
<div className="mt-4">
|
||||
<div className="mt-3">
|
||||
<div className="mb-2 flex items-center gap-1.5 text-[11px] text-muted-foreground">
|
||||
<HardDrive size={13} className="text-amber-600 dark:text-amber-400" />
|
||||
<HardDrive
|
||||
size={13}
|
||||
className="text-amber-600 dark:text-amber-400"
|
||||
/>
|
||||
<span>{t("monitor.disks")}</span>
|
||||
</div>
|
||||
<MonitorDiskUsage stats={state?.stats} limit={2} />
|
||||
</div>
|
||||
<div className="mt-3 grid grid-cols-2 gap-2 text-[11px] tabular-nums">
|
||||
<div className="mt-2 grid grid-cols-2 gap-2 text-[11px] tabular-nums">
|
||||
<span className="flex items-center gap-1.5 py-1">
|
||||
<ArrowDown size={13} className="shrink-0 text-sky-600 dark:text-sky-400" />
|
||||
<ArrowDown
|
||||
size={13}
|
||||
className="shrink-0 text-sky-600 dark:text-sky-400"
|
||||
/>
|
||||
{rate(
|
||||
state?.stats?.networkReady
|
||||
? state.stats.netRxSpeed
|
||||
@@ -706,7 +793,10 @@ export function MonitorPanels({
|
||||
)}
|
||||
</span>
|
||||
<span className="flex items-center gap-1.5 py-1">
|
||||
<ArrowUp size={13} className="shrink-0 text-violet-600 dark:text-violet-400" />
|
||||
<ArrowUp
|
||||
size={13}
|
||||
className="shrink-0 text-violet-600 dark:text-violet-400"
|
||||
/>
|
||||
{rate(
|
||||
state?.stats?.networkReady
|
||||
? state.stats.netTxSpeed
|
||||
@@ -715,127 +805,169 @@ export function MonitorPanels({
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<p className="mt-auto flex items-center gap-1.5 pt-4 text-[10px] text-muted-foreground">
|
||||
<Clock3 size={11} />
|
||||
{state?.at
|
||||
? t("monitor.updatedAt", {
|
||||
time: new Date(state.at).toLocaleTimeString(),
|
||||
})
|
||||
: t("monitor.noSamples")}
|
||||
</p>
|
||||
<div className="mt-auto flex w-full min-w-0 items-center gap-2 pt-2 text-[10px] text-muted-foreground">
|
||||
<div className="min-w-0 flex-1">
|
||||
<MonitorHostNotes notes={host?.notes} />
|
||||
</div>
|
||||
<span className="inline-flex shrink-0 items-center gap-1">
|
||||
<Clock3 size={11} />
|
||||
{state?.at
|
||||
? new Date(state.at).toLocaleTimeString()
|
||||
: t("monitor.noSamples")}
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full min-w-[840px] border-separate border-spacing-x-0 border-spacing-y-2 text-xs">
|
||||
<thead className="text-muted-foreground">
|
||||
<tr>
|
||||
{[
|
||||
"host",
|
||||
"status",
|
||||
"cpu",
|
||||
"memory",
|
||||
"disks",
|
||||
"network",
|
||||
"updated",
|
||||
].map((k) => (
|
||||
<th
|
||||
key={k}
|
||||
className="px-3 py-3 text-left font-medium"
|
||||
>
|
||||
{t(`monitor.${k}`)}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map(({ id, host, state }) => {
|
||||
const s = state?.stats;
|
||||
return (
|
||||
<tr
|
||||
key={id}
|
||||
{...dragProps(id)}
|
||||
className={cn(
|
||||
"cursor-default [&>td]:bg-card/70 [&>td]:transition-colors hover:[&>td]:bg-card [&>td:first-child]:rounded-l-xl [&>td:last-child]:rounded-r-xl",
|
||||
dragging?.panelId === panel.id && dragging.hostId === id && "cursor-grabbing opacity-40",
|
||||
dropTarget === id && "[&>td]:bg-accent",
|
||||
status(host, state) !== "live" &&
|
||||
"text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
<td className="px-3 py-3">
|
||||
<button
|
||||
draggable
|
||||
className="flex cursor-inherit items-center gap-2.5 text-left hover:underline"
|
||||
onClick={() => {
|
||||
setEditor(null);
|
||||
setDetail(id);
|
||||
}}
|
||||
>
|
||||
{host ? (
|
||||
<DistroAvatar host={host} fallback={host.label} size="md" />
|
||||
) : (
|
||||
<Server size={24} className="shrink-0 text-muted-foreground" />
|
||||
)}
|
||||
<div className="min-w-0 max-w-52">
|
||||
<div className="truncate font-medium">
|
||||
{host?.label ?? t("monitor.missingHost")}
|
||||
</div>
|
||||
<div className="truncate text-[10px] text-muted-foreground">
|
||||
{host?.hostname ?? id}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
</td>
|
||||
<td className="px-3">
|
||||
{stateBadge(host, state)}
|
||||
</td>
|
||||
<td className="px-3 tabular-nums">
|
||||
<ResourceBar label="" value={s?.cpu} />
|
||||
</td>
|
||||
<td className="px-3 tabular-nums">
|
||||
<ResourceBar
|
||||
label=""
|
||||
value={
|
||||
s?.memTotal && s.memUsed != null
|
||||
? (s.memUsed / s.memTotal) * 100
|
||||
: null
|
||||
}
|
||||
/>
|
||||
</td>
|
||||
<td className="min-w-[220px] px-3 py-3">
|
||||
<MonitorDiskUsage stats={s} />
|
||||
</td>
|
||||
<td className="px-3 tabular-nums whitespace-nowrap">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<ArrowDown size={13} className="shrink-0 text-sky-600 dark:text-sky-400" />
|
||||
<span className="sr-only">{t("monitor.download")}: </span>
|
||||
{rate(s?.networkReady ? s.netRxSpeed : null)}
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<ArrowUp size={13} className="shrink-0 text-violet-600 dark:text-violet-400" />
|
||||
<span className="sr-only">{t("monitor.upload")}: </span>
|
||||
{rate(s?.networkReady ? s.netTxSpeed : null)}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-3 whitespace-nowrap">
|
||||
{state?.at
|
||||
? new Date(state.at).toLocaleTimeString()
|
||||
: "--"}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
<p className="pt-4 text-[11px] leading-relaxed text-muted-foreground">
|
||||
{t("monitor.memoryOnlyHint")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="absolute right-3 top-3 h-7 w-7 text-muted-foreground"
|
||||
data-monitor-terminal={id}
|
||||
aria-label={`${t("monitor.openTerminal")}: ${host?.label ?? t("monitor.missingHost")}`}
|
||||
title={t("monitor.openTerminal")}
|
||||
disabled={!host}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
if (host) onConnect(host);
|
||||
}}
|
||||
>
|
||||
<Terminal size={14} />
|
||||
</Button>
|
||||
</article>
|
||||
)}
|
||||
renderCells={({ id, host, state }) => ({
|
||||
host: (<td>
|
||||
<button
|
||||
type="button"
|
||||
className="flex w-full min-w-0 items-center gap-2.5 text-left aria-disabled:no-underline hover:underline"
|
||||
aria-disabled={status(host, state) !== "live"}
|
||||
onClick={() => {
|
||||
if (status(host, state) !== "live") return;
|
||||
setEditor(null);
|
||||
setDetail(id);
|
||||
}}
|
||||
>
|
||||
{host ? (
|
||||
<DistroAvatar
|
||||
host={host}
|
||||
fallback={host.label}
|
||||
size="md"
|
||||
/>
|
||||
) : (
|
||||
<Server
|
||||
size={24}
|
||||
className="shrink-0 text-muted-foreground"
|
||||
/>
|
||||
)}
|
||||
<div className="min-w-0">
|
||||
<div className="truncate font-medium">
|
||||
{host?.label ?? t("monitor.missingHost")}
|
||||
</div>
|
||||
<div className="truncate text-[10px] text-muted-foreground">
|
||||
{host?.hostname ?? id}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
</td>),
|
||||
latency: (<td>
|
||||
<MonitorHostLatency value={state?.stats?.latencyMs} responseValue={state?.stats?.responseLatencyMs} />
|
||||
</td>),
|
||||
tags: (<td>
|
||||
<MonitorHostTags tags={host?.tags} />
|
||||
</td>),
|
||||
notes: (<td>
|
||||
<div className="min-w-0 w-full">
|
||||
<MonitorHostNotes notes={host?.notes} focusable />
|
||||
</div>
|
||||
</td>),
|
||||
cpu: (<td className="tabular-nums">
|
||||
<ResourceBar
|
||||
className="w-full"
|
||||
label=""
|
||||
value={state?.stats?.cpu}
|
||||
/>
|
||||
</td>),
|
||||
memory: (<td className="tabular-nums">
|
||||
<ResourceBar
|
||||
className="w-full"
|
||||
label=""
|
||||
value={
|
||||
state?.stats?.memTotal &&
|
||||
state.stats.memUsed != null
|
||||
? (state.stats.memUsed / state.stats.memTotal) *
|
||||
100
|
||||
: null
|
||||
}
|
||||
/>
|
||||
</td>),
|
||||
disks: (<td>
|
||||
<div className="w-full min-w-0">
|
||||
<MonitorDiskUsage stats={state?.stats} />
|
||||
</div>
|
||||
</td>),
|
||||
network: (<td
|
||||
data-monitor-cell="network"
|
||||
className="tabular-nums whitespace-nowrap"
|
||||
>
|
||||
<div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<ArrowDown
|
||||
size={13}
|
||||
className="shrink-0 text-sky-600 dark:text-sky-400"
|
||||
/>
|
||||
<span className="sr-only">
|
||||
{t("monitor.download")}:{" "}
|
||||
</span>
|
||||
{rate(
|
||||
state?.stats?.networkReady
|
||||
? state.stats.netRxSpeed
|
||||
: null,
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<ArrowUp
|
||||
size={13}
|
||||
className="shrink-0 text-violet-600 dark:text-violet-400"
|
||||
/>
|
||||
<span className="sr-only">
|
||||
{t("monitor.upload")}:{" "}
|
||||
</span>
|
||||
{rate(
|
||||
state?.stats?.networkReady
|
||||
? state.stats.netTxSpeed
|
||||
: null,
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</td>),
|
||||
updated: (<td className="whitespace-nowrap tabular-nums">
|
||||
{state?.at
|
||||
? new Date(state.at).toLocaleTimeString()
|
||||
: "--"}
|
||||
</td>),
|
||||
status: (<td>{stateBadge(host, state)}</td>),
|
||||
terminal: (<td className="sticky right-0 z-10 justify-center !bg-background before:pointer-events-none before:absolute before:inset-0 before:rounded-r-xl before:bg-card/70 group-hover:before:bg-card">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="relative h-7 w-7 text-muted-foreground"
|
||||
data-monitor-terminal={id}
|
||||
aria-label={`${t("monitor.openTerminal")}: ${host?.label ?? t("monitor.missingHost")}`}
|
||||
title={t("monitor.openTerminal")}
|
||||
disabled={!host}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
if (host) onConnect(host);
|
||||
}}
|
||||
>
|
||||
<Terminal size={14} />
|
||||
</Button>
|
||||
</td>)
|
||||
})}
|
||||
/>
|
||||
</TooltipProvider>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
@@ -1006,8 +1138,9 @@ export function MonitorPanels({
|
||||
className={monitorAsideClass}
|
||||
/>
|
||||
)}
|
||||
{detail && (
|
||||
{detail && detailAvailable && (
|
||||
<AsidePanel
|
||||
dataSection="vault-monitor-detail"
|
||||
open
|
||||
onClose={() => setDetail(null)}
|
||||
title={hostDetail?.label ?? t("monitor.missingHost")}
|
||||
@@ -1019,7 +1152,7 @@ export function MonitorPanels({
|
||||
width="w-[380px]"
|
||||
className={monitorAsideClass}
|
||||
>
|
||||
<AsidePanelContent>
|
||||
<AsidePanelContent scrollMode="native">
|
||||
<div className="flex items-center gap-3">
|
||||
{hostDetail && <DistroAvatar host={hostDetail} fallback={hostDetail.label} size="lg" />}
|
||||
<div className="min-w-0 flex-1">
|
||||
@@ -1027,73 +1160,41 @@ export function MonitorPanels({
|
||||
{stateBadge(hostDetail, detailState)}
|
||||
</div>
|
||||
</div>
|
||||
{["login", "error", "disconnected"].includes(
|
||||
detailState?.status ?? "",
|
||||
) && (
|
||||
<>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("monitor.loginHint")}
|
||||
</p>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="monitor-login-user">
|
||||
{t("monitor.username")}
|
||||
</Label>
|
||||
<Input
|
||||
id="monitor-login-user"
|
||||
autoComplete="off"
|
||||
value={
|
||||
login.hostId === detail
|
||||
? login.username
|
||||
: (hostDetail?.username ?? "")
|
||||
}
|
||||
onChange={(e) =>
|
||||
setLogin((prev) => ({
|
||||
hostId: detail,
|
||||
username: e.target.value,
|
||||
password: prev.hostId === detail ? prev.password : "",
|
||||
}))
|
||||
}
|
||||
/>
|
||||
<Label htmlFor="monitor-login-password">
|
||||
{t("monitor.password")}
|
||||
</Label>
|
||||
<Input
|
||||
id="monitor-login-password"
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
value={login.hostId === detail ? login.password : ""}
|
||||
onChange={(e) =>
|
||||
setLogin((prev) => ({
|
||||
hostId: detail,
|
||||
username:
|
||||
prev.hostId === detail
|
||||
? prev.username
|
||||
: (hostDetail?.username ?? ""),
|
||||
password: e.target.value,
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
monitorActions.retry(
|
||||
detail,
|
||||
login.hostId === detail && login.password
|
||||
? {
|
||||
username: login.username,
|
||||
password: login.password,
|
||||
}
|
||||
: undefined,
|
||||
);
|
||||
setLogin({ hostId: "", username: "", password: "" });
|
||||
}}
|
||||
>
|
||||
{t("monitor.retry")}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
{renderMetrics(detailState, true)}
|
||||
{detailState?.stats && (
|
||||
detailState.stats.gpu != null ||
|
||||
detailState.stats.gpuName ||
|
||||
(detailState.stats.gpuMemTotal ?? 0) > 0
|
||||
) && (
|
||||
<section className="space-y-3" data-monitor-gpu>
|
||||
<h3 className="flex items-center gap-1.5 text-sm font-medium">
|
||||
<Gpu size={15} className="text-muted-foreground" />
|
||||
{t("systemManager.tabs.gpu")}
|
||||
</h3>
|
||||
{detailState.stats.gpuName && (
|
||||
<p className="break-words text-xs text-muted-foreground">
|
||||
{detailState.stats.gpuName}
|
||||
</p>
|
||||
)}
|
||||
<div className="space-y-1.5">
|
||||
<p className="text-[11px] text-muted-foreground">{t("systemManager.gpu.util")}</p>
|
||||
<ResourceBar label="" value={detailState.stats.gpu} />
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex justify-between gap-2 text-[11px] text-muted-foreground">
|
||||
<span>{t("systemManager.gpu.memory")}</span>
|
||||
<span className="tabular-nums">
|
||||
{capacity(detailState.stats.gpuMemUsed)} / {capacity(detailState.stats.gpuMemTotal)}
|
||||
</span>
|
||||
</div>
|
||||
<ResourceBar label="" value={
|
||||
detailState.stats.gpuMemUsed != null && (detailState.stats.gpuMemTotal ?? 0) > 0
|
||||
? detailState.stats.gpuMemUsed / detailState.stats.gpuMemTotal! * 100
|
||||
: null
|
||||
} />
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("monitor.observed", {
|
||||
count: detailState?.points.length
|
||||
@@ -1188,24 +1289,6 @@ export function MonitorPanels({
|
||||
<p>{detailState?.stats?.kernelRelease ?? "--"}</p>
|
||||
<p>{detailState?.stats?.hostname ?? "--"}</p>
|
||||
</div>
|
||||
{detailState?.status === "unsupported" && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("monitor.unsupportedHint")}
|
||||
</p>
|
||||
)}
|
||||
{!hostDetail && panel && (
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
void patchPanel({
|
||||
hostIds: panel.hostIds.filter((id) => id !== detail),
|
||||
});
|
||||
setDetail(null);
|
||||
}}
|
||||
>
|
||||
{t("monitor.removeHost")}
|
||||
</Button>
|
||||
)}
|
||||
</AsidePanelContent>
|
||||
<AsidePanelFooter>
|
||||
<Button
|
||||
@@ -1219,6 +1302,32 @@ export function MonitorPanels({
|
||||
</AsidePanelFooter>
|
||||
</AsidePanel>
|
||||
)}
|
||||
<Dialog open={!!recovery} onOpenChange={(open) => !open && setRecovery(null)}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t("monitor.retry")}</DialogTitle>
|
||||
<DialogDescription>{t("monitor.loginHint")}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<form onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
if (!recovery) return;
|
||||
monitorActions.retry(recovery.hostId, recovery.password ? {username: recovery.username, password: recovery.password} : undefined);
|
||||
setRecovery(null);
|
||||
}} className="space-y-4">
|
||||
<p className="truncate text-sm font-medium">{hostById.get(recovery?.hostId ?? "")?.label}</p>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="monitor-login-user">{t("monitor.username")}</Label>
|
||||
<Input id="monitor-login-user" autoComplete="off" value={recovery?.username ?? ""} onChange={(event) => setRecovery(previous => previous && {...previous, username: event.target.value})} />
|
||||
<Label htmlFor="monitor-login-password">{t("monitor.password")}</Label>
|
||||
<Input id="monitor-login-password" type="password" autoComplete="new-password" value={recovery?.password ?? ""} onChange={(event) => setRecovery(previous => previous && {...previous, password: event.target.value})} />
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="outline" onClick={() => setRecovery(null)}>{t("common.cancel")}</Button>
|
||||
<Button type="submit">{t("monitor.retry")}</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
<Dialog
|
||||
open={!!deleting}
|
||||
onOpenChange={(open) => !open && setDeleting(null)}
|
||||
|
||||
@@ -0,0 +1,172 @@
|
||||
# FIDO2 SSH validation without hardware
|
||||
|
||||
Run the real OpenSSH integration simulation on Linux:
|
||||
|
||||
```sh
|
||||
npm run test:fido-sim
|
||||
```
|
||||
|
||||
Prerequisites: Node.js 22+, a C compiler, OpenSSL development headers, and
|
||||
OpenSSH `ssh-keygen`, `/usr/bin/ssh-agent`, `ssh-add`, `ssh-sk-helper`, and
|
||||
`/usr/sbin/sshd`. The test runs an unprivileged server bound to a random
|
||||
localhost port and uses Netcatty's managed temporary directory. It does not
|
||||
modify system SSH configuration or the user's login agent.
|
||||
|
||||
The opt-in test compiles `scripts/fixtures/fido/provider.c` as an OpenSSH SK
|
||||
provider and exercises the production key generation, askpass and agent helpers.
|
||||
It covers both ED25519-SK and ECDSA-SK, PIN-required enrollment and signatures,
|
||||
tokenized IdentityFile references, inline handles, SSH command execution, SFTP,
|
||||
TCP forwarding, CA certificates under IdentitiesOnly, and simultaneous callers
|
||||
from separate windows. The independent OpenSSH server verifies the signatures.
|
||||
|
||||
The provider is strictly a test fixture: its opaque handles contain disposable
|
||||
software private keys, and its PIN is always `123456`. It must never be selected
|
||||
for a real account. Its resident flag support only simulates enrollment; it does
|
||||
not enumerate stored resident credentials.
|
||||
|
||||
The ordinary test suite also exercises PIN/touch helper subprocesses, modal
|
||||
cancellation, helper-exit cleanup, platform selection, resource release and SK
|
||||
signature framing. `npm test` skips the opt-in integration simulation.
|
||||
|
||||
This verifies the OpenSSH integration on Linux with a software authenticator.
|
||||
Real USB/CTAP devices and Windows/macOS installations still need device testing.
|
||||
|
||||
The Linux simulation also removes the optional `.pub` file before strict agent
|
||||
selection, runs two real native OpenSSH clients concurrently against separate
|
||||
caller-bound agent sockets, and checks native SK certificate authentication.
|
||||
It verifies that releasing a second certificate preserves the bare key and
|
||||
the first certificate while removing only the released certificate. Native
|
||||
Mosh/ET authentication uses these sockets so its signatures follow the same
|
||||
window attribution and recovery wrapper as ssh2.
|
||||
|
||||
Identity preparation and removal are serialized per underlying agent socket.
|
||||
Bare-key removal uses `ssh-add -d -k` and certificate removal has its own hold;
|
||||
staging directories remain until all associated removals have completed.
|
||||
The tests also cover partial credential-write failures, socket paths containing
|
||||
spaces, and unknown agent-extension requests followed by normal requests on
|
||||
the same connection.
|
||||
|
||||
## Native Windows integration check
|
||||
|
||||
The `windows-fido` job in `.github/workflows/test.yml` runs native Windows Node
|
||||
with real MSYS2 OpenSSH and an MSYS-built version of the disposable provider.
|
||||
It uses a managed temporary path containing spaces and exercises Windows
|
||||
named-pipe askpass IPC, enrollment and actual signatures
|
||||
for both SK algorithms, PIN plus a separately gated touch, incorrect PIN,
|
||||
missing-device cancellation with a saved password, and explicit password
|
||||
fallback. A local SSH protocol server verifies the signatures and executes a
|
||||
test command. The independent Docker/OpenSSH server check above runs on Linux.
|
||||
|
||||
This checks a specific **user-mode MSYS2 OpenSSH** configuration. It does not
|
||||
establish support for every Windows OpenSSH distribution, the Windows system
|
||||
agent service, Git for Windows, native WebAuthn dialogs, or physical USB devices.
|
||||
Windows currently has no `ssh-sk-helper` error observer: recovery must retain
|
||||
the generic verification-failed reason rather than guessing which device error
|
||||
occurred. The Unix helper's 30-second cancellation deadline also does not apply
|
||||
to this Windows path.
|
||||
|
||||
For a physical-device check of this configuration, launch Netcatty with
|
||||
`NETCATTY_SSH_AGENT_PATH` and `NETCATTY_SSH_KEYGEN_PATH` pointing to the matching
|
||||
MSYS2 executables and put that installation's `usr/bin` on `PATH`. Use its real
|
||||
built-in FIDO provider; **never** set `SSH_SK_PROVIDER` to the CI fixture. Check
|
||||
the complete application flow against a separate SSH server: select the saved
|
||||
SK identity on a host, connect, fulfill the actual PIN/touch requests, execute a
|
||||
command, and repeat with the device absent, removed, and with PIN cancellation.
|
||||
Record the Windows/OpenSSH/device versions and which dialogs are owned by the
|
||||
application or OS. A successful software-provider CI run does not replace this
|
||||
hardware check.
|
||||
|
||||
## Docker server and gated user presence
|
||||
|
||||
```sh
|
||||
npm run test:fido-docker
|
||||
```
|
||||
|
||||
Requires Linux, Docker, a C compiler, OpenSSL headers and the OpenSSH client
|
||||
tools above. Host `sshd` is not required. The command builds a disposable Debian
|
||||
OpenSSH server, publishes a random port on `127.0.0.1`, and removes its container
|
||||
and temporary files on completion. The server accepts only public-key login as
|
||||
the test user `netcatty`. Host keys are generated when the container starts.
|
||||
Only disposable public authorized keys are mounted into the server; the client
|
||||
and software provider run on the host through Netcatty's production helpers.
|
||||
|
||||
For each of ED25519-SK and ECDSA-SK, both touch-only and PIN-plus-touch modes
|
||||
are exercised. The fixture's optional `NETCATTY_FIDO_SIM_CONTROL` file gates the
|
||||
actual signature: the test first asserts that login has not completed, then
|
||||
simulates physical touch independently of the renderer. No renderer response
|
||||
is sent for OpenSSH's touch notifications. The test verifies automatic prompt
|
||||
dismissal, command execution and SFTP, as well as rejection of missing touch,
|
||||
device removal, wrong/cancelled PIN, and signed responses missing the UP or UV
|
||||
flag. The latter assertions also check the independent server's rejection log.
|
||||
A fresh login after each group of failures checks that the agent remains usable.
|
||||
The fixture accelerates the simulated touch timeout to three seconds; it does
|
||||
not reproduce device retry counters, PIN lockout or CTAP/USB transport.
|
||||
|
||||
## Expected security-key flow
|
||||
|
||||
This feature implements **FIDO2 OpenSSH security keys**, not the YubiKey PIV or
|
||||
OpenPGP applications. The public key must first be installed on the SSH server.
|
||||
During login, OpenSSH asks the device to sign; normal keys require touch, while
|
||||
`verify-required` additionally requests user verification (typically the device
|
||||
PIN). The private key remains on the real hardware; Netcatty stores its handle.
|
||||
Resident credentials support recovery of handles on another machine, and do not
|
||||
remove the presence/verification requirement.
|
||||
|
||||
OpenSSH's `notify_start()` invokes askpass with `SSH_ASKPASS_PROMPT=none` and
|
||||
terminates the notification helper when the hardware operation finishes. That
|
||||
mode is forwarded to the UI as `notificationOnly`: there is no Continue button,
|
||||
and the dialog closes automatically when the helper exits. Closing this hint
|
||||
only dismisses the notification; it does not authorize or cancel the hardware
|
||||
operation. PIN prompts still have explicit Continue and Cancel actions. OpenSSH
|
||||
can combine PIN and touch instructions into one prompt, so the UI also explains
|
||||
that the user should touch the device after entering the PIN. The exact prompt
|
||||
order depends on the OpenSSH/device implementation; the UI follows its requests.
|
||||
For the agent's combined PIN/presence prompt, the UI keeps a touch-only waiting
|
||||
hint after PIN submission, tied to the real signing operation's completion.
|
||||
|
||||
The feature is vendor-neutral: compatible security keys from other vendors can
|
||||
be used. Check the device's algorithm support: ECDSA-SK and ED25519-SK are not
|
||||
universally interchangeable. PIN/resident/biometric support also varies. A
|
||||
touch indicates physical presence; it does not imply fingerprint recognition.
|
||||
|
||||
### Recovery and alternative authentication
|
||||
|
||||
On supported Unix OpenSSH installations, Netcatty wraps `ssh-sk-helper` to
|
||||
observe its framed error reply before `ssh-agent` collapses it into generic
|
||||
agent failure. The wrapper forwards the request/reply bytes unchanged and never
|
||||
logs request contents, PINs or signatures. It pairs with the selected OpenSSH
|
||||
helper, honors an existing `SSH_SK_HELPER`, and lives in the managed temp dir.
|
||||
Unrecognized helper reply versions retain generic recovery while their bytes
|
||||
are forwarded unchanged.
|
||||
If no compatible wrapper is available (including native Windows agents), use
|
||||
the generic verification-failed message instead of inventing a missing-device
|
||||
diagnosis. Real Windows/macOS device behavior still requires hardware testing.
|
||||
|
||||
| Situation | Behavior |
|
||||
| --- | --- |
|
||||
| Matching device unavailable | Show the selected key's label and ask to connect its device; Retry continues the pending login. |
|
||||
| Waiting for device confirmation | Show a touch/verification hint; the device completes it automatically. Closing the hint only hides it. |
|
||||
| Device operation exceeds 30 seconds | Stop that helper operation, discard late signatures and offer Retry. PIN entry time is excluded. |
|
||||
| Device removed while waiting | The helper's device-unavailable result returns to the connection/retry prompt. |
|
||||
| PIN rejected | Explain that the device PIN was not accepted; require an explicit retry, without guessing remaining attempts. |
|
||||
| Other helper failure | Offer generic recovery; do not claim the device is missing. |
|
||||
| Saved password available | Offer an explicit "Use configured password" action after a failed device operation. Cancel does not select the password. |
|
||||
| Device removed after SSH login | The established SSH session continues; the device signs authentication, not each terminal command. |
|
||||
|
||||
The configured SSH authentication deadline still bounds the pending connection.
|
||||
Device recovery also closes when its owning session/window is torn down. A
|
||||
lost prompt window or explicit cancellation fails closed; late results cannot
|
||||
authorize the cancelled attempt.
|
||||
|
||||
The Docker suite accelerates the helper deadline to 1.5 seconds and verifies
|
||||
both key algorithms, device insertion/retry, removal, timeout, incorrect PIN,
|
||||
unknown/unsupported failures, explicit password fallback and cancellation with
|
||||
a valid saved password. UI recordings shorten the deadline to 4.5 seconds and
|
||||
use the complete host -> key selection -> production SSH bridge -> Docker shell
|
||||
path; only the Electron transport and physical authenticator are simulated.
|
||||
|
||||
References:
|
||||
|
||||
- [Yubico: Securing SSH with FIDO2](https://developers.yubico.com/SSH/Securing_SSH_with_FIDO2.html)
|
||||
- [OpenSSH notify_start/notify_complete](https://github.com/openssh/openssh-portable/blob/V_10_0_P2/readpass.c)
|
||||
- [OpenSSH agent security-key signing](https://github.com/openssh/openssh-portable/blob/V_10_0_P2/ssh-agent.c)
|
||||
@@ -0,0 +1,63 @@
|
||||
# Catty prompt cache repair and verification
|
||||
|
||||
## Scope and issue history
|
||||
|
||||
- Original report: [#3545](https://github.com/binaricat/Netcatty/issues/3545), duplicate [#3546](https://github.com/binaricat/Netcatty/issues/3546).
|
||||
- [#3547](https://github.com/binaricat/Netcatty/pull/3547) moved static guidelines before scope/session details. That repair remains valid, but its closing comment did not claim a measured DeepSeek result.
|
||||
- This change starts from main `b5fee9632ea012e5c15fbdae454dec2aa7d32572` and repairs additional cache invalidation and usage-reporting paths.
|
||||
|
||||
## Confirmed defects and changes
|
||||
|
||||
1. Completed terminal results were rewritten when the next user turn rebuilt history. Keep the already bounded result, with secret redaction, and let context budgeting own necessary reductions.
|
||||
2. Live SDK results use JSON while saved history uses text. Apply the same bounded compression to both before sending them, avoiding a second, different representation on the next turn.
|
||||
3. Routine output cleanup inserted the latest goal and pending handles at the start of otherwise intact history. Reserve that reinjection for actual budget pressure or summarization.
|
||||
4. Large-output handle notices were replaced at the front on every step. Results already containing a handle need no duplicate notice. Announce missing handles once at the tail and preserve prior notices; continue to reserve their budget if older results are pruned.
|
||||
5. DeepSeek's documented `prompt_cache_hit_tokens` was lost when the OpenAI-compatible SDK parsed responses. Normalize it to the standard cached-token field before parsing, preserving an existing standard value, including zero. Cover streaming and non-streaming responses.
|
||||
6. Catty omitted cache counts from its usage events and saved assistant messages. Preserve provider counts and attach aggregate usage after the final assistant message has been created.
|
||||
7. Native Anthropic requests lacked cache breakpoints. Cache the static system block and a bounded conversation tail; leave implicit-cache OpenAI/Google requests untouched and preserve signed reasoning content. Enable these vendor fields only for the official HTTPS Anthropic endpoint (including the SDK's default endpoint), not third-party Messages-compatible gateways. Use the shared resolved SDK address, including OpenRouter/Ollama fallbacks when a stored URL is empty. Review follow-ups add endpoint gating and full-driver gateway/fallback regressions; they do not change the measured DeepSeek path.
|
||||
|
||||
## Comparison with mature agents and official guidance
|
||||
|
||||
- [Codex prompt-caching tests](https://github.com/openai/codex/blob/8c076942cb70fba8e5c4732c23f1faabc4aed484/codex-rs/core/tests/suite/prompt_caching.rs): exercise stable tools and append-only request prefixes. The new tests compare the actual serialized requests produced by Catty's driver, SDK and bridge adapter across consecutive turns.
|
||||
- [OpenCode cache policy](https://github.com/anomalyco/opencode/blob/c38d83c0c4f92466ff9c8e12b8a741698e811012/packages/ai/src/cache-policy.ts): use provider-specific explicit cache boundaries where required and leave implicit-cache protocols alone.
|
||||
- [DeepSeek context caching](https://api-docs.deepseek.com/guides/kv_cache/): exact shared prefixes, asynchronous/best-effort cache persistence and provider-reported hit/miss counts. Live probes wait between turns and use the official endpoint.
|
||||
- [Anthropic prompt caching](https://platform.claude.com/docs/en/build-with-claude/prompt-caching): explicit boundaries, tool/system/message ordering and a limit of four breakpoints. Catty uses at most three automatic markers.
|
||||
- [OpenAI prompt caching](https://developers.openai.com/api/docs/guides/prompt-caching): stable shared prefixes. No additional explicit cache-control fields are sent on this path.
|
||||
|
||||
## Live verification
|
||||
|
||||
The final fixed Catty driver was bundled from the isolated worktree and executed inside the running Electron renderer, using the app's existing IPC and configured `https://api.deepseek.com` service, model `deepseek-v4-flash`. The API key stayed inside the app's credential handling.
|
||||
|
||||
Each run contains six consecutive turns for each of three scenarios: plain chat, one command per turn, and a command producing 14,000 synthetic log lines followed by a bounded saved-output read. Model requests and usage are real. Tool execution uses an in-memory fixture; no command is sent to the user's connected SSH hosts. The prompts contain only synthetic session/host information.
|
||||
|
||||
The results file records every provider-reported input/cache/output count, per-step counts and errors. The rate is total cached input divided by total input, including all model calls in a turn. It is not an average of rounded percentages.
|
||||
|
||||
Two complete runs of the final source produced 36 turns / 72 real model requests, with zero errors:
|
||||
|
||||
| Scenario | Turns | Weighted cache hit rate | Per-turn range |
|
||||
| --- | ---: | ---: | ---: |
|
||||
| Plain chat | 12 | 98.55% | 98.19%-98.92% |
|
||||
| Command execution | 12 | 98.59% | 98.12%-99.03% |
|
||||
| Large command output + saved-output read | 12 | 97.80% | 97.30%-98.32% |
|
||||
|
||||
Raw per-turn and per-step counters are in [the JSON evidence file](./catty-prompt-cache-live-2026-10-18.json). These are warm-cache measurements; an earlier probe's first request correctly reported zero cached input before subsequent requests reached about 98%. The earlier probe is excluded from the final-source totals.
|
||||
|
||||
The production chat-message component was rendered with the live conversation in Electron. Its usage footer showed input, output, cache and reasoning counts; opening the command entry showed the expected fixture result. No product UI layout was changed.
|
||||
|
||||
Limits: live provider evidence covers the configured DeepSeek model. Native Anthropic request formatting and usage preservation are verified with the real SDK and a protocol fixture, not a paid Claude session. Cache hits remain provider-managed; a new prefix, necessary compaction or expired cache can legitimately miss.
|
||||
|
||||
## Automated verification
|
||||
|
||||
- Full AI/history/UI-related suite after the endpoint review follow-ups: 600 tests passed.
|
||||
- New regression coverage includes six consecutive plain-chat turns, six command turns, six large-file turns, six large-terminal-output turns, long-output handles, streaming/non-streaming DeepSeek usage, aggregate multi-step usage and native Anthropic boundaries.
|
||||
- Production build passed.
|
||||
- Full lint passed with eight existing warnings; final changed-file lint passed without warnings.
|
||||
- Type checking matches the untouched main baseline: 758 existing errors, zero additional errors after normalizing checkout paths and line numbers.
|
||||
|
||||
Full-repository verification also found setup and unrelated timing limits:
|
||||
|
||||
- The first run passed 13,968 tests and skipped 36, with one failed file caused by the fresh worktree missing the nested `plugin-cli/node_modules/yauzl` dependency. Linking the existing dependency directory repaired the setup; all 29 CLI tests then passed.
|
||||
- A repeat full run hit the unchanged `large directory progress coalesces synchronous persistence work` timing assertion (2,638,624 characters persisted against a 588,644-character snapshot). That assertion passed in the prior full run. All 102 tests in its file passed on isolated rerun; the same assertion also passed on untouched main. This is an observed full-run timing fluctuation, not a claimed baseline reproduction or an SFTP repair.
|
||||
- The Homebrew Node 22 binary is broken locally (missing shared library); verification uses an isolated official npm Node 22 runtime. `TMPDIR=/tmp` selects Netcatty's shorter private per-user temporary directory for Unix-socket tests. The default macOS path exceeded the FIDO test socket limit; no product source was changed to accommodate this environment.
|
||||
|
||||
The full suite is therefore not reported as unconditionally green. No Catty/cache test failed in the final implementation.
|
||||
@@ -0,0 +1,955 @@
|
||||
{
|
||||
"date": "2026-10-18",
|
||||
"endpoint": "https://api.deepseek.com",
|
||||
"model": "deepseek-v4-flash",
|
||||
"input": "synthetic prompts and in-memory tool output",
|
||||
"totalTurns": 36,
|
||||
"totalModelCalls": 72,
|
||||
"summary": [
|
||||
{
|
||||
"kind": "chat",
|
||||
"turns": 12,
|
||||
"inputTokens": 162870,
|
||||
"cachedTokens": 160512,
|
||||
"weightedHitRate": 98.55,
|
||||
"minTurnRate": 98.19,
|
||||
"maxTurnRate": 98.92
|
||||
},
|
||||
{
|
||||
"kind": "command",
|
||||
"turns": 12,
|
||||
"inputTokens": 341856,
|
||||
"cachedTokens": 337024,
|
||||
"weightedHitRate": 98.59,
|
||||
"minTurnRate": 98.12,
|
||||
"maxTurnRate": 99.03
|
||||
},
|
||||
{
|
||||
"kind": "large-output",
|
||||
"turns": 12,
|
||||
"inputTokens": 598639,
|
||||
"cachedTokens": 585472,
|
||||
"weightedHitRate": 97.8,
|
||||
"minTurnRate": 97.3,
|
||||
"maxTurnRate": 98.32
|
||||
}
|
||||
],
|
||||
"runs": [
|
||||
{
|
||||
"model": "deepseek-v4-flash",
|
||||
"rows": [
|
||||
{
|
||||
"kind": "chat",
|
||||
"round": 1,
|
||||
"inputTokens": 13500,
|
||||
"cachedTokens": 13312,
|
||||
"hitRate": 98.61,
|
||||
"outputTokens": 21,
|
||||
"modelCalls": 1,
|
||||
"toolResults": 0,
|
||||
"milliseconds": 690
|
||||
},
|
||||
{
|
||||
"kind": "chat",
|
||||
"round": 2,
|
||||
"inputTokens": 13529,
|
||||
"cachedTokens": 13312,
|
||||
"hitRate": 98.4,
|
||||
"outputTokens": 4,
|
||||
"modelCalls": 1,
|
||||
"toolResults": 0,
|
||||
"milliseconds": 898
|
||||
},
|
||||
{
|
||||
"kind": "chat",
|
||||
"round": 3,
|
||||
"inputTokens": 13558,
|
||||
"cachedTokens": 13312,
|
||||
"hitRate": 98.19,
|
||||
"outputTokens": 4,
|
||||
"modelCalls": 1,
|
||||
"toolResults": 0,
|
||||
"milliseconds": 658
|
||||
},
|
||||
{
|
||||
"kind": "chat",
|
||||
"round": 4,
|
||||
"inputTokens": 13587,
|
||||
"cachedTokens": 13440,
|
||||
"hitRate": 98.92,
|
||||
"outputTokens": 4,
|
||||
"modelCalls": 1,
|
||||
"toolResults": 0,
|
||||
"milliseconds": 982
|
||||
},
|
||||
{
|
||||
"kind": "chat",
|
||||
"round": 5,
|
||||
"inputTokens": 13616,
|
||||
"cachedTokens": 13440,
|
||||
"hitRate": 98.71,
|
||||
"outputTokens": 4,
|
||||
"modelCalls": 1,
|
||||
"toolResults": 0,
|
||||
"milliseconds": 611
|
||||
},
|
||||
{
|
||||
"kind": "chat",
|
||||
"round": 6,
|
||||
"inputTokens": 13645,
|
||||
"cachedTokens": 13440,
|
||||
"hitRate": 98.5,
|
||||
"outputTokens": 4,
|
||||
"modelCalls": 1,
|
||||
"toolResults": 0,
|
||||
"milliseconds": 676
|
||||
},
|
||||
{
|
||||
"kind": "command",
|
||||
"round": 1,
|
||||
"inputTokens": 27473,
|
||||
"cachedTokens": 27008,
|
||||
"hitRate": 98.31,
|
||||
"outputTokens": 384,
|
||||
"modelCalls": 2,
|
||||
"toolResults": 1,
|
||||
"milliseconds": 5711
|
||||
},
|
||||
{
|
||||
"kind": "command",
|
||||
"round": 2,
|
||||
"inputTokens": 28156,
|
||||
"cachedTokens": 27776,
|
||||
"hitRate": 98.65,
|
||||
"outputTokens": 93,
|
||||
"modelCalls": 2,
|
||||
"toolResults": 2,
|
||||
"milliseconds": 4606
|
||||
},
|
||||
{
|
||||
"kind": "command",
|
||||
"round": 3,
|
||||
"inputTokens": 28538,
|
||||
"cachedTokens": 28160,
|
||||
"hitRate": 98.68,
|
||||
"outputTokens": 83,
|
||||
"modelCalls": 2,
|
||||
"toolResults": 3,
|
||||
"milliseconds": 4738
|
||||
},
|
||||
{
|
||||
"kind": "command",
|
||||
"round": 4,
|
||||
"inputTokens": 28940,
|
||||
"cachedTokens": 28544,
|
||||
"hitRate": 98.63,
|
||||
"outputTokens": 113,
|
||||
"modelCalls": 2,
|
||||
"toolResults": 4,
|
||||
"milliseconds": 5000
|
||||
},
|
||||
{
|
||||
"kind": "command",
|
||||
"round": 5,
|
||||
"inputTokens": 29342,
|
||||
"cachedTokens": 29056,
|
||||
"hitRate": 99.03,
|
||||
"outputTokens": 83,
|
||||
"modelCalls": 2,
|
||||
"toolResults": 5,
|
||||
"milliseconds": 5011
|
||||
},
|
||||
{
|
||||
"kind": "command",
|
||||
"round": 6,
|
||||
"inputTokens": 29714,
|
||||
"cachedTokens": 29312,
|
||||
"hitRate": 98.65,
|
||||
"outputTokens": 83,
|
||||
"modelCalls": 2,
|
||||
"toolResults": 6,
|
||||
"milliseconds": 4517
|
||||
},
|
||||
{
|
||||
"kind": "large-output",
|
||||
"round": 1,
|
||||
"inputTokens": 42570,
|
||||
"cachedTokens": 41472,
|
||||
"hitRate": 97.42,
|
||||
"outputTokens": 344,
|
||||
"modelCalls": 3,
|
||||
"toolResults": 2,
|
||||
"milliseconds": 6895
|
||||
},
|
||||
{
|
||||
"kind": "large-output",
|
||||
"round": 2,
|
||||
"inputTokens": 45647,
|
||||
"cachedTokens": 44416,
|
||||
"hitRate": 97.3,
|
||||
"outputTokens": 163,
|
||||
"modelCalls": 3,
|
||||
"toolResults": 4,
|
||||
"milliseconds": 5644
|
||||
},
|
||||
{
|
||||
"kind": "large-output",
|
||||
"round": 3,
|
||||
"inputTokens": 48547,
|
||||
"cachedTokens": 47488,
|
||||
"hitRate": 97.82,
|
||||
"outputTokens": 165,
|
||||
"modelCalls": 3,
|
||||
"toolResults": 6,
|
||||
"milliseconds": 5271
|
||||
},
|
||||
{
|
||||
"kind": "large-output",
|
||||
"round": 4,
|
||||
"inputTokens": 51449,
|
||||
"cachedTokens": 50304,
|
||||
"hitRate": 97.77,
|
||||
"outputTokens": 163,
|
||||
"modelCalls": 3,
|
||||
"toolResults": 8,
|
||||
"milliseconds": 5826
|
||||
},
|
||||
{
|
||||
"kind": "large-output",
|
||||
"round": 5,
|
||||
"inputTokens": 54353,
|
||||
"cachedTokens": 53248,
|
||||
"hitRate": 97.97,
|
||||
"outputTokens": 166,
|
||||
"modelCalls": 3,
|
||||
"toolResults": 10,
|
||||
"milliseconds": 6235
|
||||
},
|
||||
{
|
||||
"kind": "large-output",
|
||||
"round": 6,
|
||||
"inputTokens": 57280,
|
||||
"cachedTokens": 56320,
|
||||
"hitRate": 98.32,
|
||||
"outputTokens": 168,
|
||||
"modelCalls": 3,
|
||||
"toolResults": 12,
|
||||
"milliseconds": 5692
|
||||
}
|
||||
],
|
||||
"steps": [
|
||||
{
|
||||
"kind": "chat",
|
||||
"round": 1,
|
||||
"promptTokens": 13500,
|
||||
"cachedTokens": 13312,
|
||||
"completionTokens": 21
|
||||
},
|
||||
{
|
||||
"kind": "chat",
|
||||
"round": 2,
|
||||
"promptTokens": 13529,
|
||||
"cachedTokens": 13312,
|
||||
"completionTokens": 4
|
||||
},
|
||||
{
|
||||
"kind": "chat",
|
||||
"round": 3,
|
||||
"promptTokens": 13558,
|
||||
"cachedTokens": 13312,
|
||||
"completionTokens": 4
|
||||
},
|
||||
{
|
||||
"kind": "chat",
|
||||
"round": 4,
|
||||
"promptTokens": 13587,
|
||||
"cachedTokens": 13440,
|
||||
"completionTokens": 4
|
||||
},
|
||||
{
|
||||
"kind": "chat",
|
||||
"round": 5,
|
||||
"promptTokens": 13616,
|
||||
"cachedTokens": 13440,
|
||||
"completionTokens": 4
|
||||
},
|
||||
{
|
||||
"kind": "chat",
|
||||
"round": 6,
|
||||
"promptTokens": 13645,
|
||||
"cachedTokens": 13440,
|
||||
"completionTokens": 4
|
||||
},
|
||||
{
|
||||
"kind": "command",
|
||||
"round": 1,
|
||||
"promptTokens": 13531,
|
||||
"cachedTokens": 13312,
|
||||
"completionTokens": 364
|
||||
},
|
||||
{
|
||||
"kind": "command",
|
||||
"round": 1,
|
||||
"promptTokens": 13942,
|
||||
"cachedTokens": 13696,
|
||||
"completionTokens": 20
|
||||
},
|
||||
{
|
||||
"kind": "command",
|
||||
"round": 2,
|
||||
"promptTokens": 14018,
|
||||
"cachedTokens": 13824,
|
||||
"completionTokens": 73
|
||||
},
|
||||
{
|
||||
"kind": "command",
|
||||
"round": 2,
|
||||
"promptTokens": 14138,
|
||||
"cachedTokens": 13952,
|
||||
"completionTokens": 20
|
||||
},
|
||||
{
|
||||
"kind": "command",
|
||||
"round": 3,
|
||||
"promptTokens": 14214,
|
||||
"cachedTokens": 14080,
|
||||
"completionTokens": 63
|
||||
},
|
||||
{
|
||||
"kind": "command",
|
||||
"round": 3,
|
||||
"promptTokens": 14324,
|
||||
"cachedTokens": 14080,
|
||||
"completionTokens": 20
|
||||
},
|
||||
{
|
||||
"kind": "command",
|
||||
"round": 4,
|
||||
"promptTokens": 14400,
|
||||
"cachedTokens": 14208,
|
||||
"completionTokens": 93
|
||||
},
|
||||
{
|
||||
"kind": "command",
|
||||
"round": 4,
|
||||
"promptTokens": 14540,
|
||||
"cachedTokens": 14336,
|
||||
"completionTokens": 20
|
||||
},
|
||||
{
|
||||
"kind": "command",
|
||||
"round": 5,
|
||||
"promptTokens": 14616,
|
||||
"cachedTokens": 14464,
|
||||
"completionTokens": 63
|
||||
},
|
||||
{
|
||||
"kind": "command",
|
||||
"round": 5,
|
||||
"promptTokens": 14726,
|
||||
"cachedTokens": 14592,
|
||||
"completionTokens": 20
|
||||
},
|
||||
{
|
||||
"kind": "command",
|
||||
"round": 6,
|
||||
"promptTokens": 14802,
|
||||
"cachedTokens": 14592,
|
||||
"completionTokens": 63
|
||||
},
|
||||
{
|
||||
"kind": "command",
|
||||
"round": 6,
|
||||
"promptTokens": 14912,
|
||||
"cachedTokens": 14720,
|
||||
"completionTokens": 20
|
||||
},
|
||||
{
|
||||
"kind": "large-output",
|
||||
"round": 1,
|
||||
"promptTokens": 13558,
|
||||
"cachedTokens": 13312,
|
||||
"completionTokens": 243
|
||||
},
|
||||
{
|
||||
"kind": "large-output",
|
||||
"round": 1,
|
||||
"promptTokens": 14394,
|
||||
"cachedTokens": 13696,
|
||||
"completionTokens": 97
|
||||
},
|
||||
{
|
||||
"kind": "large-output",
|
||||
"round": 1,
|
||||
"promptTokens": 14618,
|
||||
"cachedTokens": 14464,
|
||||
"completionTokens": 4
|
||||
},
|
||||
{
|
||||
"kind": "large-output",
|
||||
"round": 2,
|
||||
"promptTokens": 14705,
|
||||
"cachedTokens": 14464,
|
||||
"completionTokens": 63
|
||||
},
|
||||
{
|
||||
"kind": "large-output",
|
||||
"round": 2,
|
||||
"promptTokens": 15360,
|
||||
"cachedTokens": 14720,
|
||||
"completionTokens": 96
|
||||
},
|
||||
{
|
||||
"kind": "large-output",
|
||||
"round": 2,
|
||||
"promptTokens": 15582,
|
||||
"cachedTokens": 15232,
|
||||
"completionTokens": 4
|
||||
},
|
||||
{
|
||||
"kind": "large-output",
|
||||
"round": 3,
|
||||
"promptTokens": 15669,
|
||||
"cachedTokens": 15488,
|
||||
"completionTokens": 63
|
||||
},
|
||||
{
|
||||
"kind": "large-output",
|
||||
"round": 3,
|
||||
"promptTokens": 16326,
|
||||
"cachedTokens": 15616,
|
||||
"completionTokens": 98
|
||||
},
|
||||
{
|
||||
"kind": "large-output",
|
||||
"round": 3,
|
||||
"promptTokens": 16552,
|
||||
"cachedTokens": 16384,
|
||||
"completionTokens": 4
|
||||
},
|
||||
{
|
||||
"kind": "large-output",
|
||||
"round": 4,
|
||||
"promptTokens": 16639,
|
||||
"cachedTokens": 16384,
|
||||
"completionTokens": 63
|
||||
},
|
||||
{
|
||||
"kind": "large-output",
|
||||
"round": 4,
|
||||
"promptTokens": 17294,
|
||||
"cachedTokens": 16640,
|
||||
"completionTokens": 96
|
||||
},
|
||||
{
|
||||
"kind": "large-output",
|
||||
"round": 4,
|
||||
"promptTokens": 17516,
|
||||
"cachedTokens": 17280,
|
||||
"completionTokens": 4
|
||||
},
|
||||
{
|
||||
"kind": "large-output",
|
||||
"round": 5,
|
||||
"promptTokens": 17603,
|
||||
"cachedTokens": 17408,
|
||||
"completionTokens": 63
|
||||
},
|
||||
{
|
||||
"kind": "large-output",
|
||||
"round": 5,
|
||||
"promptTokens": 18261,
|
||||
"cachedTokens": 17536,
|
||||
"completionTokens": 99
|
||||
},
|
||||
{
|
||||
"kind": "large-output",
|
||||
"round": 5,
|
||||
"promptTokens": 18489,
|
||||
"cachedTokens": 18304,
|
||||
"completionTokens": 4
|
||||
},
|
||||
{
|
||||
"kind": "large-output",
|
||||
"round": 6,
|
||||
"promptTokens": 18576,
|
||||
"cachedTokens": 18432,
|
||||
"completionTokens": 63
|
||||
},
|
||||
{
|
||||
"kind": "large-output",
|
||||
"round": 6,
|
||||
"promptTokens": 19236,
|
||||
"cachedTokens": 18560,
|
||||
"completionTokens": 101
|
||||
},
|
||||
{
|
||||
"kind": "large-output",
|
||||
"round": 6,
|
||||
"promptTokens": 19468,
|
||||
"cachedTokens": 19328,
|
||||
"completionTokens": 4
|
||||
}
|
||||
],
|
||||
"errors": []
|
||||
},
|
||||
{
|
||||
"model": "deepseek-v4-flash",
|
||||
"rows": [
|
||||
{
|
||||
"kind": "chat",
|
||||
"round": 1,
|
||||
"inputTokens": 13500,
|
||||
"cachedTokens": 13312,
|
||||
"hitRate": 98.61,
|
||||
"outputTokens": 57,
|
||||
"modelCalls": 1,
|
||||
"toolResults": 0,
|
||||
"milliseconds": 1278
|
||||
},
|
||||
{
|
||||
"kind": "chat",
|
||||
"round": 2,
|
||||
"inputTokens": 13529,
|
||||
"cachedTokens": 13312,
|
||||
"hitRate": 98.4,
|
||||
"outputTokens": 4,
|
||||
"modelCalls": 1,
|
||||
"toolResults": 0,
|
||||
"milliseconds": 658
|
||||
},
|
||||
{
|
||||
"kind": "chat",
|
||||
"round": 3,
|
||||
"inputTokens": 13558,
|
||||
"cachedTokens": 13312,
|
||||
"hitRate": 98.19,
|
||||
"outputTokens": 4,
|
||||
"modelCalls": 1,
|
||||
"toolResults": 0,
|
||||
"milliseconds": 809
|
||||
},
|
||||
{
|
||||
"kind": "chat",
|
||||
"round": 4,
|
||||
"inputTokens": 13587,
|
||||
"cachedTokens": 13440,
|
||||
"hitRate": 98.92,
|
||||
"outputTokens": 4,
|
||||
"modelCalls": 1,
|
||||
"toolResults": 0,
|
||||
"milliseconds": 958
|
||||
},
|
||||
{
|
||||
"kind": "chat",
|
||||
"round": 5,
|
||||
"inputTokens": 13616,
|
||||
"cachedTokens": 13440,
|
||||
"hitRate": 98.71,
|
||||
"outputTokens": 4,
|
||||
"modelCalls": 1,
|
||||
"toolResults": 0,
|
||||
"milliseconds": 857
|
||||
},
|
||||
{
|
||||
"kind": "chat",
|
||||
"round": 6,
|
||||
"inputTokens": 13645,
|
||||
"cachedTokens": 13440,
|
||||
"hitRate": 98.5,
|
||||
"outputTokens": 4,
|
||||
"modelCalls": 1,
|
||||
"toolResults": 0,
|
||||
"milliseconds": 1061
|
||||
},
|
||||
{
|
||||
"kind": "command",
|
||||
"round": 1,
|
||||
"inputTokens": 27300,
|
||||
"cachedTokens": 26880,
|
||||
"hitRate": 98.46,
|
||||
"outputTokens": 195,
|
||||
"modelCalls": 2,
|
||||
"toolResults": 1,
|
||||
"milliseconds": 4773
|
||||
},
|
||||
{
|
||||
"kind": "command",
|
||||
"round": 2,
|
||||
"inputTokens": 27785,
|
||||
"cachedTokens": 27264,
|
||||
"hitRate": 98.12,
|
||||
"outputTokens": 84,
|
||||
"modelCalls": 2,
|
||||
"toolResults": 2,
|
||||
"milliseconds": 4529
|
||||
},
|
||||
{
|
||||
"kind": "command",
|
||||
"round": 3,
|
||||
"inputTokens": 28142,
|
||||
"cachedTokens": 27776,
|
||||
"hitRate": 98.7,
|
||||
"outputTokens": 67,
|
||||
"modelCalls": 2,
|
||||
"toolResults": 3,
|
||||
"milliseconds": 4584
|
||||
},
|
||||
{
|
||||
"kind": "command",
|
||||
"round": 4,
|
||||
"inputTokens": 28482,
|
||||
"cachedTokens": 28032,
|
||||
"hitRate": 98.42,
|
||||
"outputTokens": 67,
|
||||
"modelCalls": 2,
|
||||
"toolResults": 4,
|
||||
"milliseconds": 4706
|
||||
},
|
||||
{
|
||||
"kind": "command",
|
||||
"round": 5,
|
||||
"inputTokens": 28822,
|
||||
"cachedTokens": 28416,
|
||||
"hitRate": 98.59,
|
||||
"outputTokens": 67,
|
||||
"modelCalls": 2,
|
||||
"toolResults": 5,
|
||||
"milliseconds": 4558
|
||||
},
|
||||
{
|
||||
"kind": "command",
|
||||
"round": 6,
|
||||
"inputTokens": 29162,
|
||||
"cachedTokens": 28800,
|
||||
"hitRate": 98.76,
|
||||
"outputTokens": 67,
|
||||
"modelCalls": 2,
|
||||
"toolResults": 6,
|
||||
"milliseconds": 4496
|
||||
},
|
||||
{
|
||||
"kind": "large-output",
|
||||
"round": 1,
|
||||
"inputTokens": 42428,
|
||||
"cachedTokens": 41344,
|
||||
"hitRate": 97.45,
|
||||
"outputTokens": 270,
|
||||
"modelCalls": 3,
|
||||
"toolResults": 2,
|
||||
"milliseconds": 5945
|
||||
},
|
||||
{
|
||||
"kind": "large-output",
|
||||
"round": 2,
|
||||
"inputTokens": 45447,
|
||||
"cachedTokens": 44416,
|
||||
"hitRate": 97.73,
|
||||
"outputTokens": 164,
|
||||
"modelCalls": 3,
|
||||
"toolResults": 4,
|
||||
"milliseconds": 5125
|
||||
},
|
||||
{
|
||||
"kind": "large-output",
|
||||
"round": 3,
|
||||
"inputTokens": 48356,
|
||||
"cachedTokens": 47232,
|
||||
"hitRate": 97.68,
|
||||
"outputTokens": 166,
|
||||
"modelCalls": 3,
|
||||
"toolResults": 6,
|
||||
"milliseconds": 5238
|
||||
},
|
||||
{
|
||||
"kind": "large-output",
|
||||
"round": 4,
|
||||
"inputTokens": 51279,
|
||||
"cachedTokens": 50176,
|
||||
"hitRate": 97.85,
|
||||
"outputTokens": 167,
|
||||
"modelCalls": 3,
|
||||
"toolResults": 8,
|
||||
"milliseconds": 5535
|
||||
},
|
||||
{
|
||||
"kind": "large-output",
|
||||
"round": 5,
|
||||
"inputTokens": 54195,
|
||||
"cachedTokens": 53120,
|
||||
"hitRate": 98.02,
|
||||
"outputTokens": 164,
|
||||
"modelCalls": 3,
|
||||
"toolResults": 10,
|
||||
"milliseconds": 5977
|
||||
},
|
||||
{
|
||||
"kind": "large-output",
|
||||
"round": 6,
|
||||
"inputTokens": 57088,
|
||||
"cachedTokens": 55936,
|
||||
"hitRate": 97.98,
|
||||
"outputTokens": 162,
|
||||
"modelCalls": 3,
|
||||
"toolResults": 12,
|
||||
"milliseconds": 5391
|
||||
}
|
||||
],
|
||||
"steps": [
|
||||
{
|
||||
"kind": "chat",
|
||||
"round": 1,
|
||||
"promptTokens": 13500,
|
||||
"cachedTokens": 13312,
|
||||
"completionTokens": 57
|
||||
},
|
||||
{
|
||||
"kind": "chat",
|
||||
"round": 2,
|
||||
"promptTokens": 13529,
|
||||
"cachedTokens": 13312,
|
||||
"completionTokens": 4
|
||||
},
|
||||
{
|
||||
"kind": "chat",
|
||||
"round": 3,
|
||||
"promptTokens": 13558,
|
||||
"cachedTokens": 13312,
|
||||
"completionTokens": 4
|
||||
},
|
||||
{
|
||||
"kind": "chat",
|
||||
"round": 4,
|
||||
"promptTokens": 13587,
|
||||
"cachedTokens": 13440,
|
||||
"completionTokens": 4
|
||||
},
|
||||
{
|
||||
"kind": "chat",
|
||||
"round": 5,
|
||||
"promptTokens": 13616,
|
||||
"cachedTokens": 13440,
|
||||
"completionTokens": 4
|
||||
},
|
||||
{
|
||||
"kind": "chat",
|
||||
"round": 6,
|
||||
"promptTokens": 13645,
|
||||
"cachedTokens": 13440,
|
||||
"completionTokens": 4
|
||||
},
|
||||
{
|
||||
"kind": "command",
|
||||
"round": 1,
|
||||
"promptTokens": 13531,
|
||||
"cachedTokens": 13312,
|
||||
"completionTokens": 191
|
||||
},
|
||||
{
|
||||
"kind": "command",
|
||||
"round": 1,
|
||||
"promptTokens": 13769,
|
||||
"cachedTokens": 13568,
|
||||
"completionTokens": 4
|
||||
},
|
||||
{
|
||||
"kind": "command",
|
||||
"round": 2,
|
||||
"promptTokens": 13829,
|
||||
"cachedTokens": 13568,
|
||||
"completionTokens": 80
|
||||
},
|
||||
{
|
||||
"kind": "command",
|
||||
"round": 2,
|
||||
"promptTokens": 13956,
|
||||
"cachedTokens": 13696,
|
||||
"completionTokens": 4
|
||||
},
|
||||
{
|
||||
"kind": "command",
|
||||
"round": 3,
|
||||
"promptTokens": 14016,
|
||||
"cachedTokens": 13824,
|
||||
"completionTokens": 63
|
||||
},
|
||||
{
|
||||
"kind": "command",
|
||||
"round": 3,
|
||||
"promptTokens": 14126,
|
||||
"cachedTokens": 13952,
|
||||
"completionTokens": 4
|
||||
},
|
||||
{
|
||||
"kind": "command",
|
||||
"round": 4,
|
||||
"promptTokens": 14186,
|
||||
"cachedTokens": 13952,
|
||||
"completionTokens": 63
|
||||
},
|
||||
{
|
||||
"kind": "command",
|
||||
"round": 4,
|
||||
"promptTokens": 14296,
|
||||
"cachedTokens": 14080,
|
||||
"completionTokens": 4
|
||||
},
|
||||
{
|
||||
"kind": "command",
|
||||
"round": 5,
|
||||
"promptTokens": 14356,
|
||||
"cachedTokens": 14208,
|
||||
"completionTokens": 63
|
||||
},
|
||||
{
|
||||
"kind": "command",
|
||||
"round": 5,
|
||||
"promptTokens": 14466,
|
||||
"cachedTokens": 14208,
|
||||
"completionTokens": 4
|
||||
},
|
||||
{
|
||||
"kind": "command",
|
||||
"round": 6,
|
||||
"promptTokens": 14526,
|
||||
"cachedTokens": 14336,
|
||||
"completionTokens": 63
|
||||
},
|
||||
{
|
||||
"kind": "command",
|
||||
"round": 6,
|
||||
"promptTokens": 14636,
|
||||
"cachedTokens": 14464,
|
||||
"completionTokens": 4
|
||||
},
|
||||
{
|
||||
"kind": "large-output",
|
||||
"round": 1,
|
||||
"promptTokens": 13558,
|
||||
"cachedTokens": 13312,
|
||||
"completionTokens": 166
|
||||
},
|
||||
{
|
||||
"kind": "large-output",
|
||||
"round": 1,
|
||||
"promptTokens": 14320,
|
||||
"cachedTokens": 13696,
|
||||
"completionTokens": 100
|
||||
},
|
||||
{
|
||||
"kind": "large-output",
|
||||
"round": 1,
|
||||
"promptTokens": 14550,
|
||||
"cachedTokens": 14336,
|
||||
"completionTokens": 4
|
||||
},
|
||||
{
|
||||
"kind": "large-output",
|
||||
"round": 2,
|
||||
"promptTokens": 14637,
|
||||
"cachedTokens": 14464,
|
||||
"completionTokens": 63
|
||||
},
|
||||
{
|
||||
"kind": "large-output",
|
||||
"round": 2,
|
||||
"promptTokens": 15293,
|
||||
"cachedTokens": 14592,
|
||||
"completionTokens": 97
|
||||
},
|
||||
{
|
||||
"kind": "large-output",
|
||||
"round": 2,
|
||||
"promptTokens": 15517,
|
||||
"cachedTokens": 15360,
|
||||
"completionTokens": 4
|
||||
},
|
||||
{
|
||||
"kind": "large-output",
|
||||
"round": 3,
|
||||
"promptTokens": 15604,
|
||||
"cachedTokens": 15360,
|
||||
"completionTokens": 63
|
||||
},
|
||||
{
|
||||
"kind": "large-output",
|
||||
"round": 3,
|
||||
"promptTokens": 16262,
|
||||
"cachedTokens": 15616,
|
||||
"completionTokens": 99
|
||||
},
|
||||
{
|
||||
"kind": "large-output",
|
||||
"round": 3,
|
||||
"promptTokens": 16490,
|
||||
"cachedTokens": 16256,
|
||||
"completionTokens": 4
|
||||
},
|
||||
{
|
||||
"kind": "large-output",
|
||||
"round": 4,
|
||||
"promptTokens": 16577,
|
||||
"cachedTokens": 16384,
|
||||
"completionTokens": 63
|
||||
},
|
||||
{
|
||||
"kind": "large-output",
|
||||
"round": 4,
|
||||
"promptTokens": 17236,
|
||||
"cachedTokens": 16512,
|
||||
"completionTokens": 100
|
||||
},
|
||||
{
|
||||
"kind": "large-output",
|
||||
"round": 4,
|
||||
"promptTokens": 17466,
|
||||
"cachedTokens": 17280,
|
||||
"completionTokens": 4
|
||||
},
|
||||
{
|
||||
"kind": "large-output",
|
||||
"round": 5,
|
||||
"promptTokens": 17553,
|
||||
"cachedTokens": 17408,
|
||||
"completionTokens": 63
|
||||
},
|
||||
{
|
||||
"kind": "large-output",
|
||||
"round": 5,
|
||||
"promptTokens": 18209,
|
||||
"cachedTokens": 17536,
|
||||
"completionTokens": 97
|
||||
},
|
||||
{
|
||||
"kind": "large-output",
|
||||
"round": 5,
|
||||
"promptTokens": 18433,
|
||||
"cachedTokens": 18176,
|
||||
"completionTokens": 4
|
||||
},
|
||||
{
|
||||
"kind": "large-output",
|
||||
"round": 6,
|
||||
"promptTokens": 18520,
|
||||
"cachedTokens": 18304,
|
||||
"completionTokens": 63
|
||||
},
|
||||
{
|
||||
"kind": "large-output",
|
||||
"round": 6,
|
||||
"promptTokens": 19174,
|
||||
"cachedTokens": 18432,
|
||||
"completionTokens": 95
|
||||
},
|
||||
{
|
||||
"kind": "large-output",
|
||||
"round": 6,
|
||||
"promptTokens": 19394,
|
||||
"cachedTokens": 19200,
|
||||
"completionTokens": 4
|
||||
}
|
||||
],
|
||||
"errors": []
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import {
|
||||
SK_ECDSA_NISTP256,
|
||||
SK_SSH_ED25519,
|
||||
decodeOpenSshPrivateKeyBody,
|
||||
detectFidoSshKeyType,
|
||||
extractOpenSshPublicKeyType,
|
||||
isSkPrivateKey,
|
||||
isSkPublicKey,
|
||||
requiresFidoSshAgentAuth,
|
||||
} from "./fidoSsh.ts";
|
||||
|
||||
const skEdPub =
|
||||
"sk-ssh-ed25519@openssh.com AAAAGnNrLXNzaC1lZDI1NTE5QG9wZW5zc2guY29tAAAAIAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAIAAAABHNzaDo= user@fido";
|
||||
|
||||
const skEcdsaPub =
|
||||
"sk-ecdsa-sha2-nistp256@openssh.com AAAAInNrLWVjZHNhLXNoYTItbmlzdHAyNTZAb3BlbnNzaC5jb20AAAAIbmlzdHAyNTYAAABBBAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAIAAAABHNzaDo= user@fido";
|
||||
|
||||
test("extractOpenSshPublicKeyType recognizes sk algorithms", () => {
|
||||
assert.equal(extractOpenSshPublicKeyType(skEdPub), SK_SSH_ED25519);
|
||||
assert.equal(extractOpenSshPublicKeyType(skEcdsaPub), SK_ECDSA_NISTP256);
|
||||
assert.equal(extractOpenSshPublicKeyType("ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIJust test"), "ssh-ed25519");
|
||||
assert.equal(isSkPublicKey(skEdPub), true);
|
||||
assert.equal(isSkPublicKey("ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIJust test"), false);
|
||||
});
|
||||
|
||||
test("extractOpenSshPublicKeyType recognizes OpenSSH sk certificate algorithms", () => {
|
||||
const skEdCert =
|
||||
"sk-ssh-ed25519-cert-v01@openssh.com AAAAGnNrLXNzaC1lZDI1NTE5LWNlcnQtdjAxQG9wZW5zc2guY29tAAAA user@fido";
|
||||
const skEcdsaCert =
|
||||
"sk-ecdsa-sha2-nistp256-cert-v01@openssh.com AAAAInNrLWVjZHNhLXNoYTItbmlzdHAyNTYtY2VydC12MDFAb3BlbnNzaC5jb20AAAA user@fido";
|
||||
assert.equal(extractOpenSshPublicKeyType(skEdCert), SK_SSH_ED25519);
|
||||
assert.equal(extractOpenSshPublicKeyType(skEcdsaCert), SK_ECDSA_NISTP256);
|
||||
assert.equal(isSkPublicKey(skEdCert), true);
|
||||
assert.equal(detectFidoSshKeyType({ publicKey: skEdCert }), "ED25519-SK");
|
||||
assert.equal(requiresFidoSshAgentAuth({ publicKey: skEcdsaCert }), true);
|
||||
});
|
||||
|
||||
test("detectFidoSshKeyType maps vault and OpenSSH types", () => {
|
||||
assert.equal(detectFidoSshKeyType({ type: "ED25519-SK" }), "ED25519-SK");
|
||||
assert.equal(detectFidoSshKeyType({ publicKey: skEdPub }), "ED25519-SK");
|
||||
assert.equal(detectFidoSshKeyType({ publicKey: skEcdsaPub }), "ECDSA-SK");
|
||||
assert.equal(detectFidoSshKeyType({ type: "ED25519", publicKey: "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIJust test" }), undefined);
|
||||
});
|
||||
|
||||
/** Real-shaped PEM: sk type only appears after base64 decode (`@` not in b64 alphabet). */
|
||||
function makeSkPrivatePem(algo: string): string {
|
||||
const raw = Buffer.from(`openssh-key-v1\0\0\0\0\0none\0\0\0\0\0\0\0\0\0\x01${algo}`);
|
||||
const body = raw.toString("base64");
|
||||
// Guard: the PEM text must NOT contain the plain algorithm (skeptic regression).
|
||||
const pem = `-----BEGIN OPENSSH PRIVATE KEY-----\n${body}\n-----END OPENSSH PRIVATE KEY-----\n`;
|
||||
assert.equal(pem.includes("@openssh.com"), false);
|
||||
return pem;
|
||||
}
|
||||
|
||||
test("isSkPrivateKey detects OpenSSH sk key handles", () => {
|
||||
const pem = makeSkPrivatePem(SK_SSH_ED25519);
|
||||
assert.equal(isSkPrivateKey(pem), true);
|
||||
assert.equal(isSkPrivateKey("-----BEGIN OPENSSH PRIVATE KEY-----\nAAAA\n-----END OPENSSH PRIVATE KEY-----"), false);
|
||||
assert.equal(requiresFidoSshAgentAuth({ publicKey: skEdPub }), true);
|
||||
assert.equal(requiresFidoSshAgentAuth({ privateKey: pem }), true);
|
||||
assert.equal(requiresFidoSshAgentAuth({ type: "ED25519", privateKey: "soft-key" }), false);
|
||||
});
|
||||
|
||||
test("detectFidoSshKeyType reads sk algorithm from base64-only private PEM", () => {
|
||||
const edPem = makeSkPrivatePem(SK_SSH_ED25519);
|
||||
const ecPem = makeSkPrivatePem(SK_ECDSA_NISTP256);
|
||||
assert.equal(detectFidoSshKeyType({ privateKey: edPem }), "ED25519-SK");
|
||||
assert.equal(detectFidoSshKeyType({ privateKey: ecPem }), "ECDSA-SK");
|
||||
});
|
||||
|
||||
test("decodeOpenSshPrivateKeyBody works without Node Buffer (renderer)", () => {
|
||||
const pem = makeSkPrivatePem(SK_SSH_ED25519);
|
||||
const originalBuffer = globalThis.Buffer;
|
||||
// @ts-expect-error intentional renderer simulation
|
||||
delete globalThis.Buffer;
|
||||
try {
|
||||
assert.equal(typeof globalThis.Buffer, "undefined");
|
||||
const decoded = decodeOpenSshPrivateKeyBody(pem);
|
||||
assert.ok(decoded);
|
||||
assert.equal(decoded.includes(SK_SSH_ED25519), true);
|
||||
assert.equal(detectFidoSshKeyType({ privateKey: pem }), "ED25519-SK");
|
||||
} finally {
|
||||
globalThis.Buffer = originalBuffer;
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,137 @@
|
||||
/**
|
||||
* OpenSSH FIDO2 / U2F security-key (sk-*) helpers.
|
||||
*
|
||||
* Netcatty does not drive CTAP itself. SK keys authenticate through the system
|
||||
* OpenSSH agent + ssh-sk-helper (libfido2). These helpers detect sk material so
|
||||
* the app can force the agent path instead of software privateKey signing.
|
||||
*/
|
||||
|
||||
export const SK_SSH_ED25519 = "sk-ssh-ed25519@openssh.com";
|
||||
export const SK_ECDSA_NISTP256 = "sk-ecdsa-sha2-nistp256@openssh.com";
|
||||
|
||||
export type SkPublicKeyType = typeof SK_SSH_ED25519 | typeof SK_ECDSA_NISTP256;
|
||||
export type FidoSshKeyType = "ED25519-SK" | "ECDSA-SK";
|
||||
|
||||
const SK_PUBLIC_KEY_TYPES = new Set<string>([SK_SSH_ED25519, SK_ECDSA_NISTP256]);
|
||||
|
||||
const OPENSSH_PRIVATE_KEY_RE =
|
||||
/-----BEGIN OPENSSH PRIVATE KEY-----([\s\S]+?)-----END OPENSSH PRIVATE KEY-----/;
|
||||
|
||||
export const isSkPublicKeyType = (type: string | undefined | null): type is SkPublicKeyType =>
|
||||
typeof type === "string" && SK_PUBLIC_KEY_TYPES.has(type.trim());
|
||||
|
||||
export const isFidoSshVaultKeyType = (
|
||||
type: string | undefined | null,
|
||||
): type is FidoSshKeyType => type === "ED25519-SK" || type === "ECDSA-SK";
|
||||
|
||||
/** Parse the algorithm field from an OpenSSH authorized_keys / .pub line. */
|
||||
export const extractOpenSshPublicKeyType = (
|
||||
publicKey: string | undefined | null,
|
||||
): string | undefined => {
|
||||
if (typeof publicKey !== "string") return undefined;
|
||||
const line = publicKey
|
||||
.split(/\r?\n/)
|
||||
.map((entry) => entry.trim())
|
||||
.find((entry) => entry && !entry.startsWith("#"));
|
||||
if (!line) return undefined;
|
||||
// OpenSSH certificate algorithms embed `-cert-v01` *before* `@openssh.com`
|
||||
// (e.g. `sk-ssh-ed25519-cert-v01@openssh.com`), not after the RP id.
|
||||
const skCert = /^(sk-(?:ssh-ed25519|ecdsa-sha2-nistp256))-cert-v0[01]@openssh\.com\s+/.exec(line);
|
||||
if (skCert) return `${skCert[1]}@openssh.com`;
|
||||
const match = /^(sk-(?:ssh-ed25519|ecdsa-sha2-nistp256)@openssh\.com|ssh-(?:rsa|ed25519|dss)|ecdsa-sha2-nistp(?:256|384|521))(?:-cert-v0[01]@openssh\.com)?\s+/.exec(line);
|
||||
return match?.[1];
|
||||
};
|
||||
|
||||
export const isSkPublicKey = (publicKey: string | undefined | null): boolean =>
|
||||
isSkPublicKeyType(extractOpenSshPublicKeyType(publicKey));
|
||||
|
||||
/**
|
||||
* Renderer-safe base64 decode. Avoids Node `Buffer`, which is unavailable in
|
||||
* Electron windows with `nodeIntegration: false`.
|
||||
*/
|
||||
const decodeBase64ToBinaryString = (payload: string): string | null => {
|
||||
const normalized = payload.replace(/\s+/g, "");
|
||||
if (!normalized) return null;
|
||||
try {
|
||||
if (typeof atob === "function") {
|
||||
return atob(normalized);
|
||||
}
|
||||
} catch {
|
||||
// fall through to Node Buffer when present (tests / main)
|
||||
}
|
||||
if (typeof Buffer !== "undefined") {
|
||||
try {
|
||||
return Buffer.from(normalized, "base64").toString("binary");
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Decode OpenSSH private-key PEM body (base64) for algorithm probing.
|
||||
* Real sk-* handles only contain `sk-*-@openssh.com` *inside* the base64 payload
|
||||
* (`@` is not a base64 alphabet character), so raw-text regex on PEM is insufficient.
|
||||
*/
|
||||
export const decodeOpenSshPrivateKeyBody = (
|
||||
privateKey: string | undefined | null,
|
||||
): string | null => {
|
||||
if (typeof privateKey !== "string" || !privateKey.trim()) return null;
|
||||
const match = OPENSSH_PRIVATE_KEY_RE.exec(privateKey);
|
||||
if (!match) return null;
|
||||
return decodeBase64ToBinaryString(match[1]);
|
||||
};
|
||||
|
||||
const skTypeInText = (text: string | undefined | null): FidoSshKeyType | undefined => {
|
||||
if (!text) return undefined;
|
||||
if (text.includes(SK_ECDSA_NISTP256) || /ecdsa-sk|sk-ecdsa/i.test(text)) return "ECDSA-SK";
|
||||
if (text.includes(SK_SSH_ED25519) || /ed25519-sk|sk-ssh-ed25519/i.test(text)) return "ED25519-SK";
|
||||
return undefined;
|
||||
};
|
||||
|
||||
/**
|
||||
* Best-effort detection of an OpenSSH sk-* private key (handle) blob.
|
||||
* Decodes the base64 body and looks for the algorithm string.
|
||||
*/
|
||||
export const isSkPrivateKey = (privateKey: string | undefined | null): boolean => {
|
||||
if (typeof privateKey !== "string" || !privateKey.trim()) return false;
|
||||
const decoded = decodeOpenSshPrivateKeyBody(privateKey);
|
||||
if (decoded) {
|
||||
return decoded.includes(SK_SSH_ED25519) || decoded.includes(SK_ECDSA_NISTP256);
|
||||
}
|
||||
// Some exporters only put the type on a comment line / nearby text.
|
||||
return /sk-(?:ssh-ed25519|ecdsa-sha2-nistp256)@openssh\.com/.test(privateKey);
|
||||
};
|
||||
|
||||
export const detectFidoSshKeyType = (args: {
|
||||
type?: string | null;
|
||||
publicKey?: string | null;
|
||||
privateKey?: string | null;
|
||||
}): FidoSshKeyType | undefined => {
|
||||
if (isFidoSshVaultKeyType(args.type)) return args.type;
|
||||
const pubType = extractOpenSshPublicKeyType(args.publicKey);
|
||||
if (pubType === SK_SSH_ED25519) return "ED25519-SK";
|
||||
if (pubType === SK_ECDSA_NISTP256) return "ECDSA-SK";
|
||||
if (isSkPrivateKey(args.privateKey)) {
|
||||
const decoded = decodeOpenSshPrivateKeyBody(args.privateKey);
|
||||
return skTypeInText(decoded) || skTypeInText(args.privateKey) || "ED25519-SK";
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
/** True when vault/host material must authenticate via agent + hardware helper. */
|
||||
export const requiresFidoSshAgentAuth = (args: {
|
||||
type?: string | null;
|
||||
publicKey?: string | null;
|
||||
privateKey?: string | null;
|
||||
}): boolean => Boolean(detectFidoSshKeyType(args) || isSkPublicKey(args.publicKey) || isSkPrivateKey(args.privateKey));
|
||||
|
||||
export const fidoSshKeyTypeToOpenSsh = (type: FidoSshKeyType): SkPublicKeyType =>
|
||||
type === "ECDSA-SK" ? SK_ECDSA_NISTP256 : SK_SSH_ED25519;
|
||||
|
||||
export const openSshSkTypeToVaultKeyType = (type: string | undefined | null): FidoSshKeyType | undefined => {
|
||||
if (type === SK_SSH_ED25519) return "ED25519-SK";
|
||||
if (type === SK_ECDSA_NISTP256) return "ECDSA-SK";
|
||||
return undefined;
|
||||
};
|
||||
@@ -0,0 +1,65 @@
|
||||
import type { BrowserBookmark } from './browserBookmark';
|
||||
|
||||
export class HostBrowserIconError extends Error {
|
||||
constructor(public readonly reason: 'file-type' | 'file-size' | 'decode') {
|
||||
super(reason);
|
||||
this.name = 'HostBrowserIconError';
|
||||
}
|
||||
}
|
||||
|
||||
export interface HostBrowserTab {
|
||||
id: string;
|
||||
url: string;
|
||||
title: string;
|
||||
loading: boolean;
|
||||
canGoBack: boolean;
|
||||
canGoForward: boolean;
|
||||
iconDataUrl: string | null;
|
||||
iconGlyph: string | null;
|
||||
iconColor: string | null;
|
||||
}
|
||||
|
||||
export interface HostBrowserState {
|
||||
hostId: string;
|
||||
label: string;
|
||||
route: 'connecting' | 'connected' | 'disconnected';
|
||||
error: string | null;
|
||||
activeTabId: string | null;
|
||||
tabs: HostBrowserTab[];
|
||||
}
|
||||
|
||||
export interface HostBrowserDownload {
|
||||
id: string;
|
||||
hostId: string;
|
||||
filename: string;
|
||||
url: string;
|
||||
savePath: string;
|
||||
status: 'waiting' | 'progressing' | 'completed' | 'cancelled' | 'interrupted';
|
||||
received: number;
|
||||
total: number;
|
||||
startedAt: number;
|
||||
endedAt: number | null;
|
||||
missing: boolean;
|
||||
}
|
||||
|
||||
export type HostBrowserBookmark = BrowserBookmark & {
|
||||
siteIconDataUrl?: string | null;
|
||||
siteIconGlyph?: string | null;
|
||||
siteIconColor?: string | null;
|
||||
};
|
||||
|
||||
export type HostBrowserCommand =
|
||||
| { type: 'new-tab'; url?: string }
|
||||
| { type: 'navigate'; url: string; tabId?: string }
|
||||
| { type: 'activate' | 'close-tab' | 'back' | 'forward' | 'reload' | 'stop' | 'reset-icon'; tabId?: string }
|
||||
| { type: 'reorder'; tabId: string; beforeTabId: string }
|
||||
| { type: 'set-icon'; tabId: string; dataUrl?: string | null; glyph: string; color: string }
|
||||
| { type: 'retry' };
|
||||
|
||||
export type HostBrowserShortcut = { type: 'focus-address' | 'new-tab' | 'close-tab' | 'reload'; tabId: string };
|
||||
export type HostBrowserPanelEvent = { browserId: string } & (
|
||||
| { type: 'state'; state: HostBrowserState }
|
||||
| { type: 'downloads'; downloads: HostBrowserDownload[]; startedId?: string }
|
||||
| { type: 'bookmarks' }
|
||||
| { type: 'shortcut'; shortcut: HostBrowserShortcut }
|
||||
);
|
||||
@@ -9,6 +9,7 @@
|
||||
*/
|
||||
|
||||
import type { KeyType } from './models';
|
||||
import { detectFidoSshKeyType } from './fidoSsh';
|
||||
|
||||
export interface SshKeyTypeInfo {
|
||||
type: KeyType;
|
||||
@@ -154,6 +155,8 @@ export const detectSshKeyType = (
|
||||
privateKey?: string | null,
|
||||
publicKey?: string | null,
|
||||
): SshKeyTypeInfo => {
|
||||
const fidoType = detectFidoSshKeyType({ privateKey, publicKey });
|
||||
if (fidoType) return { type: fidoType };
|
||||
const fromPrivate = privateKey ? detectFromPrivateKey(privateKey) : undefined;
|
||||
if (fromPrivate) return fromPrivate;
|
||||
const fromPublic = publicKey ? detectFromPublicKey(publicKey) : undefined;
|
||||
|
||||
@@ -313,7 +313,8 @@ export interface Host {
|
||||
order?: number;
|
||||
}
|
||||
|
||||
export type KeyType = 'RSA' | 'ECDSA' | 'ED25519';
|
||||
/** Software keys plus OpenSSH FIDO2 security-key types (agent + hardware only). */
|
||||
export type KeyType = 'RSA' | 'ECDSA' | 'ED25519' | 'ED25519-SK' | 'ECDSA-SK';
|
||||
type KeySource = 'generated' | 'imported' | 'reference';
|
||||
export type KeyCategory = 'key' | 'certificate' | 'identity';
|
||||
type IdentityAuthMethod = 'password' | 'key' | 'certificate';
|
||||
|
||||
@@ -22,6 +22,8 @@ export type MonitorHostState = {
|
||||
| "disconnected"
|
||||
| "limit";
|
||||
at: number;
|
||||
/** GPU publication can advance independently of the primary sample/history. */
|
||||
statsAt?: number;
|
||||
/** Omitted by incremental replies when the receiver already has this sample. */
|
||||
stats?: MonitorStats | null;
|
||||
points: MonitorPoint[];
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import {
|
||||
MONITOR_COLUMNS,
|
||||
clampMonitorColumnWidth,
|
||||
normalizeMonitorColumnWidths,
|
||||
normalizeMonitorHiddenColumns,
|
||||
monitorGridColumns,
|
||||
} from "./monitorTableLayout";
|
||||
|
||||
test("saved column sizes cannot hide data or create unbounded table widths", () => {
|
||||
const widths = normalizeMonitorColumnWidths({
|
||||
host: -1,
|
||||
latency: Infinity,
|
||||
notes: 100000,
|
||||
terminal: NaN,
|
||||
unknown: 400,
|
||||
});
|
||||
assert.equal(widths.host, 160);
|
||||
assert.equal(widths.latency, 90);
|
||||
assert.equal(widths.notes, 640);
|
||||
assert.equal(widths.terminal, 56);
|
||||
assert.equal(Object.keys(widths).length, MONITOR_COLUMNS.length);
|
||||
assert.deepEqual(
|
||||
normalizeMonitorColumnWidths(null),
|
||||
normalizeMonitorColumnWidths({}),
|
||||
);
|
||||
for (const column of MONITOR_COLUMNS) {
|
||||
assert.equal(clampMonitorColumnWidth(column.key, -100), column.min);
|
||||
assert.equal(
|
||||
clampMonitorColumnWidth(column.key, 10000),
|
||||
column.key === "terminal" ? 200 : 640,
|
||||
);
|
||||
}
|
||||
});
|
||||
test("virtual card rows adapt to narrow panes and do not allocate empty columns", () => {
|
||||
assert.equal(monitorGridColumns(200, 1000), 1);
|
||||
assert.equal(monitorGridColumns(575, 1000), 1);
|
||||
assert.equal(monitorGridColumns(576, 1000), 2);
|
||||
assert.equal(monitorGridColumns(1248, 1000), 4);
|
||||
assert.equal(monitorGridColumns(1248, 2), 2);
|
||||
assert.equal(monitorGridColumns(0, 1000), 1);
|
||||
});
|
||||
|
||||
test("hidden columns always preserve host and terminal and discard unknown settings", () => {
|
||||
assert.deepEqual(
|
||||
normalizeMonitorHiddenColumns([
|
||||
"host",
|
||||
"terminal",
|
||||
"cpu",
|
||||
"cpu",
|
||||
"unknown",
|
||||
"status",
|
||||
]),
|
||||
["cpu", "status"],
|
||||
);
|
||||
assert.deepEqual(normalizeMonitorHiddenColumns(null), []);
|
||||
assert.deepEqual(normalizeMonitorHiddenColumns({ cpu: true }), []);
|
||||
assert.equal(
|
||||
normalizeMonitorHiddenColumns(MONITOR_COLUMNS.map((c) => c.key)).length,
|
||||
MONITOR_COLUMNS.length - 2,
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,64 @@
|
||||
export const MONITOR_COLUMNS = [
|
||||
{ key: "host", width: 220, min: 160 },
|
||||
{ key: "latency", width: 90, min: 80 },
|
||||
{ key: "tags", width: 150, min: 90 },
|
||||
{ key: "notes", width: 180, min: 110 },
|
||||
{ key: "cpu", width: 120, min: 120 },
|
||||
{ key: "memory", width: 120, min: 120 },
|
||||
{ key: "disks", width: 240, min: 220 },
|
||||
{ key: "network", width: 150, min: 140 },
|
||||
{ key: "updated", width: 110, min: 95 },
|
||||
{ key: "status", width: 100, min: 90 },
|
||||
{ key: "terminal", width: 56, min: 56 },
|
||||
] as const;
|
||||
export type MonitorColumn = (typeof MONITOR_COLUMNS)[number]["key"];
|
||||
export type MonitorColumnWidths = Record<MonitorColumn, number>;
|
||||
export const MONITOR_CARD_HEIGHT = 368;
|
||||
|
||||
export function clampMonitorColumnWidth(
|
||||
key: MonitorColumn,
|
||||
width: number,
|
||||
): number {
|
||||
const column = MONITOR_COLUMNS.find((column) => column.key === key)!;
|
||||
return Number.isFinite(width)
|
||||
? Math.round(
|
||||
Math.max(column.min, Math.min(key === "terminal" ? 200 : 640, width)),
|
||||
)
|
||||
: column.width;
|
||||
}
|
||||
export function normalizeMonitorColumnWidths(
|
||||
value: unknown,
|
||||
): MonitorColumnWidths {
|
||||
const stored =
|
||||
value && typeof value === "object"
|
||||
? (value as Record<string, unknown>)
|
||||
: {};
|
||||
return Object.fromEntries(
|
||||
MONITOR_COLUMNS.map((column) => [
|
||||
column.key,
|
||||
clampMonitorColumnWidth(
|
||||
column.key,
|
||||
typeof stored[column.key] === "number"
|
||||
? (stored[column.key] as number)
|
||||
: column.width,
|
||||
),
|
||||
]),
|
||||
) as MonitorColumnWidths;
|
||||
}
|
||||
export function monitorGridColumns(width: number, count: number): number {
|
||||
return Math.max(
|
||||
1,
|
||||
Math.min(count, Math.floor((Math.max(0, width) + 16) / 296)),
|
||||
);
|
||||
}
|
||||
|
||||
/** Only optional columns can be hidden, including when restoring old or corrupt settings. */
|
||||
export function normalizeMonitorHiddenColumns(value: unknown): MonitorColumn[] {
|
||||
return MONITOR_COLUMNS.filter(
|
||||
({ key }) =>
|
||||
key !== "host" &&
|
||||
key !== "terminal" &&
|
||||
Array.isArray(value) &&
|
||||
value.includes(key),
|
||||
).map(({ key }) => key);
|
||||
}
|
||||
@@ -99,6 +99,36 @@ test("resolveBridgeSshAgentAuth carries system agent settings without private ma
|
||||
);
|
||||
});
|
||||
|
||||
test("resolveBridgeSshAgentAuth forces agent auth for FIDO2 SK vault keys", () => {
|
||||
const skKey: SSHKey = {
|
||||
id: "sk-1",
|
||||
label: "YubiKey",
|
||||
type: "ED25519-SK",
|
||||
privateKey: "-----BEGIN OPENSSH PRIVATE KEY-----\nsk-ssh-ed25519@openssh.com\n-----END OPENSSH PRIVATE KEY-----",
|
||||
publicKey: "sk-ssh-ed25519@openssh.com AAAAGnNrLXNzaC1lZDI1NTE5QG9wZW5zc2guY29tAAAAIAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAIAAAABHNzaDo= yubi",
|
||||
source: "imported",
|
||||
category: "key",
|
||||
created: 1,
|
||||
};
|
||||
|
||||
assert.deepEqual(
|
||||
resolveBridgeSshAgentAuth({ ...autofillBaseHost, useSshAgent: false }, skKey, "key"),
|
||||
{
|
||||
useSshAgent: true,
|
||||
identityAgent: undefined,
|
||||
identitiesOnly: true,
|
||||
addKeysToAgent: "yes",
|
||||
useKeychain: undefined,
|
||||
agentPublicKeys: [skKey.publicKey!],
|
||||
},
|
||||
);
|
||||
|
||||
assert.equal(
|
||||
resolveBridgeKeyAuth({ key: skKey }).privateKey?.includes("OPENSSH PRIVATE KEY"),
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test("resolveBridgeSshAgentAuth keeps certificate authentication independent", () => {
|
||||
assert.deepEqual(
|
||||
resolveBridgeSshAgentAuth({
|
||||
@@ -109,6 +139,85 @@ test("resolveBridgeSshAgentAuth keeps certificate authentication independent", (
|
||||
);
|
||||
});
|
||||
|
||||
test("resolveBridgeSshAgentAuth keeps agent path for FIDO SK certificates", () => {
|
||||
const skCertKey: SSHKey = {
|
||||
id: "sk-cert-1",
|
||||
label: "YubiKey cert",
|
||||
type: "ED25519-SK",
|
||||
privateKey: "-----BEGIN OPENSSH PRIVATE KEY-----\nsk-ssh-ed25519@openssh.com\n-----END OPENSSH PRIVATE KEY-----",
|
||||
publicKey: "sk-ssh-ed25519-cert-v01@openssh.com AAAAGnNrLXNzaC1lZDI1NTE5LWNlcnQtdjAxQG9wZW5zc2guY29tAAAA yubi",
|
||||
certificate: "sk-ssh-ed25519-cert-v01@openssh.com AAAAGnNrLXNzaC1lZDI1NTE5LWNlcnQtdjAxQG9wZW5zc2guY29tAAAA yubi",
|
||||
source: "imported",
|
||||
category: "certificate",
|
||||
created: 1,
|
||||
};
|
||||
|
||||
assert.deepEqual(
|
||||
resolveBridgeSshAgentAuth({ ...autofillBaseHost, useSshAgent: false }, skCertKey, "certificate"),
|
||||
{
|
||||
useSshAgent: true,
|
||||
identityAgent: undefined,
|
||||
identitiesOnly: true,
|
||||
addKeysToAgent: "yes",
|
||||
useKeychain: undefined,
|
||||
agentPublicKeys: [skCertKey.publicKey!],
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test("resolveBridgeSshAgentAuth selects staged SK certificate under IdentitiesOnly", () => {
|
||||
const barePub =
|
||||
"sk-ssh-ed25519@openssh.com AAAAGnNrLXNzaC1lZDI1NTE5QG9wZW5zc2guY29tAAAAIAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAIAAAABHNzaDo= yubi";
|
||||
const cert =
|
||||
"sk-ssh-ed25519-cert-v01@openssh.com AAAAGnNrLXNzaC1lZDI1NTE5LWNlcnQtdjAxQG9wZW5zc2guY29tAAAA yubi";
|
||||
const skCertKey: SSHKey = {
|
||||
id: "sk-cert-bare-1",
|
||||
label: "YubiKey cert staged",
|
||||
type: "ED25519-SK",
|
||||
privateKey: "-----BEGIN OPENSSH PRIVATE KEY-----\nsk-ssh-ed25519@openssh.com\n-----END OPENSSH PRIVATE KEY-----",
|
||||
publicKey: barePub,
|
||||
certificate: cert,
|
||||
source: "imported",
|
||||
category: "certificate",
|
||||
created: 1,
|
||||
};
|
||||
|
||||
assert.deepEqual(
|
||||
resolveBridgeSshAgentAuth({ ...autofillBaseHost, useSshAgent: false }, skCertKey, "certificate"),
|
||||
{
|
||||
useSshAgent: true,
|
||||
identityAgent: undefined,
|
||||
identitiesOnly: true,
|
||||
addKeysToAgent: "yes",
|
||||
useKeychain: undefined,
|
||||
agentPublicKeys: [barePub, cert],
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test("resolveBridgeSshAgentAuth forces agent for path-only SK vault type", () => {
|
||||
const skRef: SSHKey = {
|
||||
id: "sk-ref-1",
|
||||
label: "YubiKey file",
|
||||
type: "ED25519-SK",
|
||||
source: "reference",
|
||||
filePath: "/home/user/.ssh/id_ed25519_sk",
|
||||
category: "key",
|
||||
created: 1,
|
||||
};
|
||||
|
||||
assert.deepEqual(
|
||||
resolveBridgeSshAgentAuth({ ...autofillBaseHost, useSshAgent: false }, skRef, "key"),
|
||||
{
|
||||
useSshAgent: true,
|
||||
identityAgent: undefined,
|
||||
identitiesOnly: true,
|
||||
addKeysToAgent: "yes",
|
||||
useKeychain: undefined,
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test("resolveBridgeSshAgentAuth forwards a selected vault public key", () => {
|
||||
assert.deepEqual(
|
||||
resolveBridgeSshAgentAuth({
|
||||
|
||||
+91
-10
@@ -2,6 +2,7 @@ import type { GroupConfig, Host, HostAuthMethod, Identity, SSHKey } from "./mode
|
||||
import { sanitizeCredentialValue } from "./credentials";
|
||||
import { applyGroupDefaults } from "./groupConfig";
|
||||
import { isSshAgentNoneValue } from "./sshAgentSettings";
|
||||
import { requiresFidoSshAgentAuth } from "./fidoSsh";
|
||||
|
||||
type HostAuthOverride = {
|
||||
authMethod?: HostAuthMethod;
|
||||
@@ -240,10 +241,30 @@ export const resolveBridgeKeyAuth = (args: {
|
||||
passphrase?: string;
|
||||
} => {
|
||||
const { key, fallbackIdentityFilePaths, passphrase } = args;
|
||||
const isFidoSk = requiresFidoSshAgentAuth({
|
||||
type: key?.type,
|
||||
publicKey: key?.publicKey,
|
||||
privateKey: key?.privateKey,
|
||||
});
|
||||
const identityFilePaths = key?.source === "reference" && key.filePath
|
||||
? [key.filePath]
|
||||
: fallbackIdentityFilePaths;
|
||||
|
||||
// SK private material is an OpenSSH key *handle*. Pass it through so the
|
||||
// main process can load it into the system agent; never use it for local
|
||||
// software signing (startSession routes SK handles to ssh-add).
|
||||
if (isFidoSk) {
|
||||
return {
|
||||
privateKey: key?.source === "reference"
|
||||
? undefined
|
||||
: sanitizeCredentialValue(key?.privateKey),
|
||||
identityFilePaths: key?.source === "reference" && key.filePath
|
||||
? [key.filePath]
|
||||
: identityFilePaths,
|
||||
passphrase: sanitizeCredentialValue(passphrase ?? key?.passphrase),
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
privateKey: key?.source === "reference" ? undefined : sanitizeCredentialValue(key?.privateKey),
|
||||
identityFilePaths,
|
||||
@@ -251,9 +272,23 @@ export const resolveBridgeKeyAuth = (args: {
|
||||
};
|
||||
};
|
||||
|
||||
/** Preferred agent identity blobs for IdentitiesOnly selection. */
|
||||
const resolveAgentPublicKeys = (
|
||||
key?: Pick<SSHKey, "publicKey" | "certificate">,
|
||||
): string[] | undefined => {
|
||||
const keys: string[] = [];
|
||||
const publicKey = key?.publicKey?.trim();
|
||||
const certificate = key?.certificate?.trim();
|
||||
if (publicKey) keys.push(publicKey);
|
||||
// ssh-add -L lists the bare key and certificate as distinct identities; when
|
||||
// the vault stores them separately, both must be selectable under IdentitiesOnly.
|
||||
if (certificate && certificate !== publicKey) keys.push(certificate);
|
||||
return keys.length > 0 ? keys : undefined;
|
||||
};
|
||||
|
||||
export const resolveBridgeSshAgentAuth = (
|
||||
host: Pick<Host, "authMethod" | "useSshAgent" | "identityAgent" | "identityFilePaths" | "identitiesOnly" | "addKeysToAgent" | "useKeychain" | "agentForwarding">,
|
||||
key?: Pick<SSHKey, "certificate" | "publicKey" | "source" | "filePath">,
|
||||
key?: Pick<SSHKey, "certificate" | "publicKey" | "source" | "filePath" | "type" | "privateKey">,
|
||||
authMethod?: HostAuthMethod,
|
||||
): {
|
||||
useSshAgent?: boolean;
|
||||
@@ -268,25 +303,70 @@ export const resolveBridgeSshAgentAuth = (
|
||||
&& !isSshAgentNoneValue(host.identityAgent)
|
||||
? { identityAgent: host.identityAgent }
|
||||
: {};
|
||||
if (authMethod === "password" || authMethod === "certificate" || key?.certificate?.trim()) {
|
||||
|
||||
const isFidoSk = requiresFidoSshAgentAuth({
|
||||
type: key?.type,
|
||||
publicKey: key?.publicKey,
|
||||
privateKey: key?.privateKey,
|
||||
});
|
||||
|
||||
const hasAgentSelector = Boolean(
|
||||
key?.publicKey?.trim()
|
||||
|| (key?.source === "reference" && key.filePath?.trim())
|
||||
|| host.identityFilePaths?.some((filePath) => filePath.trim())
|
||||
// Inline imported sk private key handle still needs agent; selection uses
|
||||
// the public key when present, otherwise the agent is used non-strictly.
|
||||
|| (isFidoSk && key?.privateKey?.trim()),
|
||||
);
|
||||
|
||||
// FIDO2 SK keys always require the system agent (hardware signs via
|
||||
// ssh-sk-helper), whether selected explicitly or via a CA certificate
|
||||
// wrapping the SK public key.
|
||||
const fidoAgentAuth = () => {
|
||||
if (!hasAgentSelector && !key?.privateKey?.trim()) {
|
||||
return { useSshAgent: false };
|
||||
}
|
||||
const agentPublicKeys = resolveAgentPublicKeys(key);
|
||||
return {
|
||||
useSshAgent: true,
|
||||
identityAgent: host.identityAgent,
|
||||
identitiesOnly: Boolean(key?.publicKey?.trim() || (key?.source === "reference" && key.filePath)),
|
||||
addKeysToAgent: host.addKeysToAgent ?? "yes",
|
||||
useKeychain: host.useKeychain,
|
||||
...(agentPublicKeys ? { agentPublicKeys } : {}),
|
||||
};
|
||||
};
|
||||
|
||||
if (authMethod === "password") {
|
||||
return { useSshAgent: false, ...forwardingAgent };
|
||||
}
|
||||
if (authMethod === "key") {
|
||||
const hasAgentSelector = Boolean(
|
||||
key?.publicKey?.trim()
|
||||
|| (key?.source === "reference" && key.filePath?.trim())
|
||||
|| host.identityFilePaths?.some((filePath) => filePath.trim()),
|
||||
);
|
||||
|
||||
if (authMethod === "certificate" || key?.certificate?.trim()) {
|
||||
// A certificate over an SK public key still needs the agent for hardware
|
||||
// signing; only non-FIDO certificate auth bypasses the agent entirely.
|
||||
if (isFidoSk) {
|
||||
return fidoAgentAuth();
|
||||
}
|
||||
return { useSshAgent: false, ...forwardingAgent };
|
||||
}
|
||||
|
||||
if (authMethod === "key" || isFidoSk) {
|
||||
// Explicit key auth without agent only works for soft keys; FIDO SK
|
||||
// material is routed through the agent regardless of authMethod.
|
||||
if (isFidoSk) {
|
||||
return fidoAgentAuth();
|
||||
}
|
||||
if (host.useSshAgent !== true || !hasAgentSelector) {
|
||||
return { useSshAgent: false, ...forwardingAgent };
|
||||
}
|
||||
const agentPublicKeys = resolveAgentPublicKeys(key);
|
||||
return {
|
||||
useSshAgent: true,
|
||||
identityAgent: host.identityAgent,
|
||||
identitiesOnly: true,
|
||||
addKeysToAgent: host.addKeysToAgent,
|
||||
useKeychain: host.useKeychain,
|
||||
...(key?.publicKey?.trim() ? { agentPublicKeys: [key.publicKey] } : {}),
|
||||
...(agentPublicKeys ? { agentPublicKeys } : {}),
|
||||
};
|
||||
}
|
||||
if (host.useSshAgent !== true) {
|
||||
@@ -294,13 +374,14 @@ export const resolveBridgeSshAgentAuth = (
|
||||
? forwardingAgent
|
||||
: { useSshAgent: false, ...forwardingAgent };
|
||||
}
|
||||
const agentPublicKeys = resolveAgentPublicKeys(key);
|
||||
return {
|
||||
useSshAgent: true,
|
||||
identityAgent: host.identityAgent,
|
||||
identitiesOnly: host.identitiesOnly,
|
||||
addKeysToAgent: host.addKeysToAgent,
|
||||
useKeychain: host.useKeychain,
|
||||
...(key?.publicKey?.trim() ? { agentPublicKeys: [key.publicKey] } : {}),
|
||||
...(agentPublicKeys ? { agentPublicKeys } : {}),
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
"use strict";
|
||||
|
||||
/**
|
||||
* Attach a one-shot release for an owned FIDO agent / askpass lease to a
|
||||
* connection-like EventEmitter (ssh2 Client, etc.).
|
||||
*
|
||||
* @param {import("events").EventEmitter|null|undefined} conn
|
||||
* @param {{ _releaseNetcattyFidoAgent?: () => void }|null|undefined} agent
|
||||
*/
|
||||
function attachFidoAgentRelease(conn, agent) {
|
||||
if (!conn || typeof conn.once !== "function") return;
|
||||
if (!agent || typeof agent._releaseNetcattyFidoAgent !== "function") return;
|
||||
let released = false;
|
||||
const releaseOwned = () => {
|
||||
if (released) return;
|
||||
released = true;
|
||||
try { agent._releaseNetcattyFidoAgent(); } catch { /* ignore */ }
|
||||
};
|
||||
conn.once("close", releaseOwned);
|
||||
conn.once("end", releaseOwned);
|
||||
const onError = error => {
|
||||
if (error?.code !== "ERR_FIDO_CANCELLED") return;
|
||||
// ssh2 would otherwise try another key/password after an agent failure.
|
||||
// Explicitly cancelling hardware authentication must end this connection.
|
||||
try { conn.end?.(); } catch { /* already closed */ }
|
||||
};
|
||||
conn.on("error", onError);
|
||||
conn.once("close", () => conn.removeListener("error", onError));
|
||||
return releaseOwned;
|
||||
}
|
||||
|
||||
module.exports = { attachFidoAgentRelease };
|
||||
@@ -0,0 +1,76 @@
|
||||
"use strict";
|
||||
|
||||
const test = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const { EventEmitter } = require("node:events");
|
||||
const { attachFidoAgentRelease } = require("./attachFidoAgentRelease.cjs");
|
||||
|
||||
test("attachFidoAgentRelease invokes release once on close", () => {
|
||||
const conn = new EventEmitter();
|
||||
let releaseCount = 0;
|
||||
const agent = {
|
||||
_releaseNetcattyFidoAgent: () => { releaseCount += 1; },
|
||||
};
|
||||
|
||||
attachFidoAgentRelease(conn, agent);
|
||||
conn.emit("close");
|
||||
|
||||
assert.equal(releaseCount, 1);
|
||||
conn.emit("close");
|
||||
assert.equal(releaseCount, 1);
|
||||
});
|
||||
|
||||
test("attachFidoAgentRelease invokes release once on end", () => {
|
||||
const conn = new EventEmitter();
|
||||
let releaseCount = 0;
|
||||
const agent = {
|
||||
_releaseNetcattyFidoAgent: () => { releaseCount += 1; },
|
||||
};
|
||||
|
||||
attachFidoAgentRelease(conn, agent);
|
||||
conn.emit("end");
|
||||
|
||||
assert.equal(releaseCount, 1);
|
||||
conn.emit("end");
|
||||
assert.equal(releaseCount, 1);
|
||||
});
|
||||
|
||||
test("attachFidoAgentRelease is one-shot across close and end", () => {
|
||||
const conn = new EventEmitter();
|
||||
let releaseCount = 0;
|
||||
const agent = {
|
||||
_releaseNetcattyFidoAgent: () => { releaseCount += 1; },
|
||||
};
|
||||
|
||||
attachFidoAgentRelease(conn, agent);
|
||||
conn.emit("close");
|
||||
conn.emit("end");
|
||||
|
||||
assert.equal(releaseCount, 1);
|
||||
});
|
||||
|
||||
test("attachFidoAgentRelease no-ops without conn or release hook", () => {
|
||||
let releaseCount = 0;
|
||||
const agent = {
|
||||
_releaseNetcattyFidoAgent: () => { releaseCount += 1; },
|
||||
};
|
||||
|
||||
attachFidoAgentRelease(null, agent);
|
||||
attachFidoAgentRelease(new EventEmitter(), null);
|
||||
attachFidoAgentRelease(new EventEmitter(), {});
|
||||
|
||||
assert.equal(releaseCount, 0);
|
||||
});
|
||||
|
||||
test("explicit FIDO cancellation ends the transport instead of trying another credential", () => {
|
||||
const conn = new EventEmitter();
|
||||
let ended = 0;
|
||||
conn.end = () => { ended++; };
|
||||
attachFidoAgentRelease(conn, { _releaseNetcattyFidoAgent() {} });
|
||||
conn.emit("error", Object.assign(new Error("cancelled"), { code: "ERR_FIDO_CANCELLED" }));
|
||||
assert.equal(ended, 1);
|
||||
conn.emit("error", new Error("ordinary agent error"));
|
||||
assert.equal(ended, 1);
|
||||
conn.emit("close");
|
||||
assert.equal(conn.listenerCount("error"), 0);
|
||||
});
|
||||
@@ -0,0 +1,500 @@
|
||||
"use strict";
|
||||
|
||||
/**
|
||||
* Netcatty-owned ssh-agent for FIDO2 sk-* keys.
|
||||
*
|
||||
* System agents started at login often lack SSH_ASKPASS, so PIN/touch prompts
|
||||
* never reach our GUI. Spawning a short-lived agent as our child with askpass
|
||||
* env makes sk-helper prompts work inside Netcatty.
|
||||
*/
|
||||
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
const { execFile } = require("node:child_process");
|
||||
const { promisify } = require("node:util");
|
||||
const { randomUUID } = require("node:crypto");
|
||||
const {
|
||||
buildFidoAskpassEnv,
|
||||
releaseFidoAskpassLease,
|
||||
shutdownFidoAskpass,
|
||||
} = require("./fidoAskpass.cjs");
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
/** @type {{ killed: boolean, pid: number, kill: (sig?: string) => void }|null} */
|
||||
let agentChild = null;
|
||||
/** @type {string|null} */
|
||||
let agentSocket = null;
|
||||
/** @type {string|null} */
|
||||
let agentDir = null;
|
||||
let refCount = 0;
|
||||
/**
|
||||
* Monotonic id for the currently tracked agent. Cleared/restarted agents bump
|
||||
* this so stale release callbacks from earlier acquisitions cannot kill a newer
|
||||
* agent that reused the singleton slot.
|
||||
*/
|
||||
let agentGeneration = 0;
|
||||
/** @type {Promise<{ socketPath: string, askpassEnv: Record<string, string>, owned: boolean, generation: number }>|null} */
|
||||
let startingPromise = null;
|
||||
|
||||
function getTempBase() {
|
||||
const tempDirBridge = require("./tempDirBridge.cjs");
|
||||
if (typeof tempDirBridge.getTempDir !== "function") {
|
||||
throw new Error("FIDO agent requires Netcatty temp directory (tempDirBridge unavailable).");
|
||||
}
|
||||
return tempDirBridge.getTempDir();
|
||||
}
|
||||
|
||||
const WIN_SYSTEM_AGENT_PIPE = "\\\\.\\pipe\\openssh-ssh-agent";
|
||||
|
||||
/**
|
||||
* User-mode ssh-agent binaries on Windows (not the Win32-OpenSSH service host).
|
||||
* These can run as our child with SSH_ASKPASS so verify-required sk-helper
|
||||
* prompts reach the Netcatty modal.
|
||||
* @param {NodeJS.ProcessEnv} [env]
|
||||
* @returns {string[]}
|
||||
*/
|
||||
function listWindowsUserModeSshAgentCandidates(env = process.env) {
|
||||
const programFiles = env.ProgramFiles || "C:\\Program Files";
|
||||
const programFilesX86 = env["ProgramFiles(x86)"] || "C:\\Program Files (x86)";
|
||||
const localAppData = env.LOCALAPPDATA || "";
|
||||
const userProfile = env.USERPROFILE || "";
|
||||
return [
|
||||
path.join(programFiles, "Git", "usr", "bin", "ssh-agent.exe"),
|
||||
path.join(programFilesX86, "Git", "usr", "bin", "ssh-agent.exe"),
|
||||
path.join(localAppData, "Programs", "Git", "usr", "bin", "ssh-agent.exe"),
|
||||
path.join(userProfile, "scoop", "apps", "git", "current", "usr", "bin", "ssh-agent.exe"),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Pair ssh-add with a user-mode ssh-agent (same directory) when possible.
|
||||
* @param {string} sshAgentPath
|
||||
* @returns {string|null}
|
||||
*/
|
||||
function companionSshAddPath(sshAgentPath) {
|
||||
if (typeof sshAgentPath !== "string" || !sshAgentPath.trim()) return null;
|
||||
const dir = path.dirname(sshAgentPath);
|
||||
const base = path.basename(sshAgentPath).toLowerCase().replace(/\.exe$/i, "");
|
||||
if (base !== "ssh-agent") return null;
|
||||
const candidate = path.join(dir, path.basename(sshAgentPath).replace(/ssh-agent/i, "ssh-add"));
|
||||
try {
|
||||
if (fs.existsSync(candidate)) return candidate;
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function resolveSshAgentBinary(env = process.env, platform = process.platform) {
|
||||
if (typeof env.NETCATTY_SSH_AGENT_PATH === "string" && env.NETCATTY_SSH_AGENT_PATH.trim()) {
|
||||
return env.NETCATTY_SSH_AGENT_PATH.trim();
|
||||
}
|
||||
if (platform === "darwin") {
|
||||
for (const candidate of [
|
||||
"/opt/homebrew/bin/ssh-agent",
|
||||
"/usr/local/bin/ssh-agent",
|
||||
"/usr/bin/ssh-agent",
|
||||
]) {
|
||||
try {
|
||||
if (fs.existsSync(candidate)) return candidate;
|
||||
} catch {
|
||||
// continue
|
||||
}
|
||||
}
|
||||
}
|
||||
if (platform === "win32") {
|
||||
// Prefer Git/MSYS (or similar) user-mode agents over System32's service binary.
|
||||
for (const candidate of listWindowsUserModeSshAgentCandidates(env)) {
|
||||
try {
|
||||
if (fs.existsSync(candidate)) return candidate;
|
||||
} catch {
|
||||
// continue
|
||||
}
|
||||
}
|
||||
}
|
||||
return "ssh-agent";
|
||||
}
|
||||
|
||||
function parseAgentStdout(stdout) {
|
||||
const sockMatch = /SSH_AUTH_SOCK=([^;\r\n]+)/.exec(stdout || "");
|
||||
const pidMatch = /SSH_AGENT_PID=(\d+)/.exec(stdout || "");
|
||||
return {
|
||||
socketPath: sockMatch?.[1]?.trim().replace(/^(['"])(.*)\1$/, "$2") || null,
|
||||
agentPid: pidMatch ? Number(pidMatch[1]) : null,
|
||||
};
|
||||
}
|
||||
|
||||
function isProcessAlive(pid) {
|
||||
if (!pid || !Number.isFinite(pid) || pid <= 0) return false;
|
||||
try {
|
||||
process.kill(pid, 0);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function isWindowsNamedPipe(socketPath) {
|
||||
return typeof socketPath === "string"
|
||||
&& /^\\\\[.,]\\pipe\\/i.test(socketPath);
|
||||
}
|
||||
|
||||
function isAgentLive() {
|
||||
if (!agentSocket) return false;
|
||||
// Named pipes are not reliable with existsSync; rely on PID / child state.
|
||||
if (!isWindowsNamedPipe(agentSocket)) {
|
||||
try {
|
||||
if (!fs.existsSync(agentSocket)) return false;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
if (agentChild?.pid) return isProcessAlive(agentChild.pid);
|
||||
// Socket exists but no PID tracking — treat as live until connect fails.
|
||||
// System-pipe fallback sets agentChild=null; treat that as live while socket is set.
|
||||
if (!agentChild) return isWindowsNamedPipe(agentSocket);
|
||||
return !agentChild.killed;
|
||||
}
|
||||
|
||||
function clearAgentState({ kill = true } = {}) {
|
||||
if (kill && agentChild) {
|
||||
const ownedPid = agentChild.pid;
|
||||
// Only kill OUR tracked pid. Never run `ssh-agent -k` with the ambient
|
||||
// process.env SSH_AGENT_PID — that can kill the user's login agent.
|
||||
try {
|
||||
if (ownedPid && isProcessAlive(ownedPid)) {
|
||||
process.kill(ownedPid, "TERM");
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
if (ownedPid && agentSocket) {
|
||||
try {
|
||||
const sshAgent = resolveSshAgentBinary();
|
||||
// Isolate env: only our sock + pid, no inheritance of login agent vars.
|
||||
execFile(sshAgent, ["-k"], {
|
||||
env: {
|
||||
PATH: process.env.PATH || "/usr/bin:/bin",
|
||||
SSH_AUTH_SOCK: agentSocket,
|
||||
SSH_AGENT_PID: String(ownedPid),
|
||||
},
|
||||
windowsHide: true,
|
||||
timeout: 3000,
|
||||
}, () => {});
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
try {
|
||||
agentChild.kill?.("TERM");
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
agentChild = null;
|
||||
agentSocket = null;
|
||||
if (agentDir) {
|
||||
fs.promises.rm(agentDir, { recursive: true, force: true }).catch(() => {});
|
||||
agentDir = null;
|
||||
}
|
||||
refCount = 0;
|
||||
agentGeneration += 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Env for the long-lived ssh-agent process. Must not include a caller-bound
|
||||
* NETCATTY_FIDO_ASKPASS_LEASE: ssh-sk-helper for later verify-required signing
|
||||
* is a descendant of the agent and would inherit that lease, routing a second
|
||||
* window's PIN/touch prompt to the starter forever. Callers still receive a
|
||||
* per-acquire lease via askpassEnv for ssh-add. Agent-spawned prompts route to
|
||||
* the last leased signing window in fidoAskpass (not the sticky last acquire).
|
||||
* @param {Record<string, string>|null|undefined} askpassEnv
|
||||
* @returns {Record<string, string>}
|
||||
*/
|
||||
function askpassEnvForAgentProcess(askpassEnv) {
|
||||
if (!askpassEnv || typeof askpassEnv !== "object") return {};
|
||||
const { NETCATTY_FIDO_ASKPASS_LEASE: _callerLease, ...agentAskpassEnv } = askpassEnv;
|
||||
return agentAskpassEnv;
|
||||
}
|
||||
|
||||
/**
|
||||
* Acquire a Netcatty FIDO agent socket. Multiple callers share one agent.
|
||||
* Concurrent acquires are serialized via startingPromise.
|
||||
*/
|
||||
async function acquireFidoAgent(options = {}) {
|
||||
if (startingPromise) {
|
||||
const shared = await startingPromise;
|
||||
if (isAgentLive()) {
|
||||
refCount += 1;
|
||||
// Caller-specific askpass lease — never reuse the starter's resolver.
|
||||
return {
|
||||
socketPath: shared.socketPath,
|
||||
askpassEnv: buildFidoAskpassEnv({ resolveWebContents: options.resolveWebContents,
|
||||
sshBinaryPath: resolveSshAgentBinary(options.env || process.env, options.platform || process.platform),
|
||||
env: options.env }),
|
||||
owned: shared.owned,
|
||||
generation: shared.generation,
|
||||
sshAddPath: shared.sshAddPath || null,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if (isAgentLive() && agentSocket) {
|
||||
refCount += 1;
|
||||
return {
|
||||
socketPath: agentSocket,
|
||||
askpassEnv: buildFidoAskpassEnv({ resolveWebContents: options.resolveWebContents,
|
||||
sshBinaryPath: resolveSshAgentBinary(options.env || process.env, options.platform || process.platform),
|
||||
env: options.env }),
|
||||
// Preserve ownership of the live singleton (Windows system-pipe fallback is not owned).
|
||||
owned: Boolean(agentChild),
|
||||
generation: agentGeneration,
|
||||
sshAddPath: companionSshAddPath(resolveSshAgentBinary(options.env || process.env, options.platform || process.platform)),
|
||||
};
|
||||
}
|
||||
|
||||
// Dead leftover state
|
||||
if (agentSocket || agentChild) clearAgentState({ kill: true });
|
||||
|
||||
startingPromise = (async () => {
|
||||
const run = options.execFile || execFileAsync;
|
||||
const env = options.env || process.env;
|
||||
const platform = options.platform || process.platform;
|
||||
const sshAgent = resolveSshAgentBinary(env, platform);
|
||||
const askpassEnv = buildFidoAskpassEnv({ resolveWebContents: options.resolveWebContents,
|
||||
sshBinaryPath: sshAgent, env });
|
||||
const releaseStarterAskpassLease = () => {
|
||||
try {
|
||||
releaseFidoAskpassLease(askpassEnv?.NETCATTY_FIDO_ASKPASS_LEASE);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
};
|
||||
|
||||
try {
|
||||
const sshAddPath = companionSshAddPath(sshAgent);
|
||||
|
||||
// MSYS/Cygwin sockaddr_un still has the Unix 108-byte path limit. The
|
||||
// managed base already identifies Netcatty; keep the socket suffix short.
|
||||
agentDir = path.join(getTempBase(), `fa-${randomUUID().slice(0, 8)}`);
|
||||
fs.mkdirSync(agentDir, { recursive: true, mode: 0o700 });
|
||||
|
||||
// Keep SSH_ASKPASS on the agent so verify-required sk-helper prompts work,
|
||||
// but never bake the starter's caller lease into the shared process env.
|
||||
const agentProcessEnv = {
|
||||
...env, ...askpassEnvForAgentProcess(askpassEnv),
|
||||
...require("./fidoSkHelper.cjs").buildSkHelperEnv(sshAgent, askpassEnv, env, platform),
|
||||
};
|
||||
|
||||
// Windows: try a Netcatty-owned named pipe first (ssh2 speaks it natively),
|
||||
// then a filesystem socket for MSYS/Git user-mode agents.
|
||||
const sockCandidates = platform === "win32"
|
||||
? [
|
||||
`\\\\.\\pipe\\netcatty-fido-agent-${randomUUID().slice(0, 8)}`,
|
||||
path.join(agentDir, "agent.sock").replace(/\\/g, "/"),
|
||||
]
|
||||
: [path.join(agentDir, "agent.sock")];
|
||||
|
||||
let stdout = "";
|
||||
let boundSockPath = sockCandidates[0];
|
||||
let started = false;
|
||||
/** @type {Error|null} */
|
||||
let lastStartError = null;
|
||||
|
||||
for (const sockPath of sockCandidates) {
|
||||
boundSockPath = sockPath;
|
||||
try {
|
||||
const result = await run(sshAgent, ["-a", sockPath, "-s"], {
|
||||
timeout: 10000,
|
||||
windowsHide: true,
|
||||
env: agentProcessEnv,
|
||||
});
|
||||
stdout = result.stdout?.toString?.() || result.stdout || "";
|
||||
started = true;
|
||||
break;
|
||||
} catch (error) {
|
||||
lastStartError = error instanceof Error ? error : new Error(String(error));
|
||||
}
|
||||
}
|
||||
|
||||
if (!started) {
|
||||
try {
|
||||
const result = await run(sshAgent, ["-s"], {
|
||||
timeout: 10000,
|
||||
windowsHide: true,
|
||||
env: agentProcessEnv,
|
||||
});
|
||||
stdout = result.stdout?.toString?.() || result.stdout || "";
|
||||
started = true;
|
||||
} catch (fallbackError) {
|
||||
lastStartError = fallbackError instanceof Error
|
||||
? fallbackError
|
||||
: new Error(String(fallbackError));
|
||||
}
|
||||
}
|
||||
|
||||
if (started) {
|
||||
const parsed = parseAgentStdout(stdout);
|
||||
const fallbackSock = isWindowsNamedPipe(boundSockPath)
|
||||
? boundSockPath
|
||||
: (fs.existsSync(boundSockPath) ? boundSockPath : null);
|
||||
agentSocket = parsed.socketPath || fallbackSock;
|
||||
if (!agentSocket) {
|
||||
clearAgentState({ kill: true });
|
||||
const err = new Error("ssh-agent started but SSH_AUTH_SOCK was not reported.");
|
||||
err.code = "ERR_FIDO_AGENT_SOCK";
|
||||
throw err;
|
||||
}
|
||||
|
||||
// In-box Win32-OpenSSH `ssh-agent -s` often just advertises the system
|
||||
// service pipe — that is not a Netcatty child and must not be killed.
|
||||
const isSystemServicePipe = typeof agentSocket === "string"
|
||||
&& /openssh-ssh-agent$/i.test(agentSocket.replace(/\//g, "\\"));
|
||||
if (isSystemServicePipe) {
|
||||
agentChild = null;
|
||||
agentDir = null;
|
||||
refCount = 1;
|
||||
return {
|
||||
socketPath: agentSocket,
|
||||
askpassEnv,
|
||||
owned: false,
|
||||
generation: agentGeneration,
|
||||
sshAddPath: null,
|
||||
};
|
||||
}
|
||||
|
||||
if (parsed.agentPid && Number.isFinite(parsed.agentPid) && parsed.agentPid > 0) {
|
||||
const pid = parsed.agentPid;
|
||||
agentChild = {
|
||||
killed: false,
|
||||
pid,
|
||||
kill(sig = "TERM") {
|
||||
try { process.kill(pid, sig); } catch { /* ignore */ }
|
||||
this.killed = true;
|
||||
},
|
||||
};
|
||||
} else {
|
||||
// No PID — still usable via socket; kill via ssh-agent -k on release.
|
||||
agentChild = {
|
||||
killed: false,
|
||||
pid: 0,
|
||||
kill() { this.killed = true; },
|
||||
};
|
||||
}
|
||||
|
||||
refCount = 1;
|
||||
return {
|
||||
socketPath: agentSocket,
|
||||
askpassEnv,
|
||||
owned: true,
|
||||
generation: agentGeneration,
|
||||
sshAddPath,
|
||||
};
|
||||
}
|
||||
|
||||
// Win32-OpenSSH's in-box ssh-agent is a service bound to a fixed named
|
||||
// pipe and usually rejects `ssh-agent -a`. Fall back to that pipe so
|
||||
// non-verify-required keys still work, but mark owned:false so callers
|
||||
// remove any identities they ssh-add (see prepareSystemSshAgentForAuth).
|
||||
// verify-required signing still needs a prompt-capable child agent above.
|
||||
if (platform === "win32") {
|
||||
clearAgentState({ kill: false });
|
||||
agentSocket = WIN_SYSTEM_AGENT_PIPE;
|
||||
agentChild = null;
|
||||
agentDir = null;
|
||||
refCount = 1;
|
||||
return {
|
||||
socketPath: WIN_SYSTEM_AGENT_PIPE,
|
||||
askpassEnv,
|
||||
owned: false,
|
||||
generation: agentGeneration,
|
||||
sshAddPath: null,
|
||||
};
|
||||
}
|
||||
|
||||
clearAgentState({ kill: false });
|
||||
const message = lastStartError instanceof Error ? lastStartError.message : String(lastStartError || "unknown");
|
||||
const err = new Error(
|
||||
`Could not start a FIDO-capable ssh-agent (${message}). Install OpenSSH with libfido2 (macOS: brew install openssh libfido2).`,
|
||||
);
|
||||
err.code = "ERR_FIDO_AGENT_START";
|
||||
throw err;
|
||||
} catch (error) {
|
||||
// Starter allocated a caller-specific askpass lease before launch; drop it
|
||||
// whenever startup fails so WebContents resolvers are not retained.
|
||||
releaseStarterAskpassLease();
|
||||
throw error;
|
||||
}
|
||||
})();
|
||||
|
||||
try {
|
||||
return await startingPromise;
|
||||
} finally {
|
||||
startingPromise = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Release one acquisition. Pass the `generation` returned by acquireFidoAgent so
|
||||
* a late close after the agent was replaced cannot decrement/kill the newer agent.
|
||||
*/
|
||||
function releaseFidoAgent(generation) {
|
||||
if (generation !== agentGeneration) return;
|
||||
refCount = Math.max(0, refCount - 1);
|
||||
if (refCount > 0) return;
|
||||
clearAgentState({ kill: true });
|
||||
}
|
||||
|
||||
function getActiveFidoAgentSocket() {
|
||||
return isAgentLive() ? agentSocket : null;
|
||||
}
|
||||
|
||||
function shutdownFidoAgentSubsystem() {
|
||||
clearAgentState({ kill: true });
|
||||
try {
|
||||
shutdownFidoAskpass();
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
let quitHookInstalled = false;
|
||||
function installFidoAgentQuitHook() {
|
||||
if (quitHookInstalled) return;
|
||||
quitHookInstalled = true;
|
||||
const shutdown = () => {
|
||||
try { shutdownFidoAgentSubsystem(); } catch { /* ignore */ }
|
||||
};
|
||||
// Terminal/auth often runs in a utilityProcess where `app` is unavailable.
|
||||
// Use only the `exit` hook so we do not swallow SIGTERM/SIGINT default exit
|
||||
// behavior (installing those handlers without process.exit leaves workers hung).
|
||||
process.once("exit", shutdown);
|
||||
try {
|
||||
const { app } = require("electron");
|
||||
app.once("before-quit", shutdown);
|
||||
app.once("will-quit", shutdown);
|
||||
} catch {
|
||||
// non-electron / utility process
|
||||
}
|
||||
}
|
||||
|
||||
// Best-effort install when module loads in Electron main or worker.
|
||||
try { installFidoAgentQuitHook(); } catch { /* ignore */ }
|
||||
|
||||
module.exports = {
|
||||
acquireFidoAgent,
|
||||
releaseFidoAgent,
|
||||
getActiveFidoAgentSocket,
|
||||
resolveSshAgentBinary,
|
||||
parseAgentStdout,
|
||||
shutdownFidoAgentSubsystem,
|
||||
installFidoAgentQuitHook,
|
||||
isAgentLive,
|
||||
askpassEnvForAgentProcess,
|
||||
listWindowsUserModeSshAgentCandidates,
|
||||
companionSshAddPath,
|
||||
WIN_SYSTEM_AGENT_PIPE,
|
||||
// exposed for tests
|
||||
getTempBase,
|
||||
};
|
||||
@@ -0,0 +1,305 @@
|
||||
"use strict";
|
||||
|
||||
const test = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const {
|
||||
parseAgentStdout,
|
||||
isAgentLive,
|
||||
releaseFidoAgent,
|
||||
getActiveFidoAgentSocket,
|
||||
shutdownFidoAgentSubsystem,
|
||||
acquireFidoAgent,
|
||||
getTempBase,
|
||||
} = require("./fidoAgentManager.cjs");
|
||||
|
||||
test("parseAgentStdout extracts sock and pid", () => {
|
||||
const parsed = parseAgentStdout(
|
||||
"SSH_AUTH_SOCK=/tmp/agent.123; export SSH_AUTH_SOCK;\nSSH_AGENT_PID=9999; export SSH_AGENT_PID;\n",
|
||||
);
|
||||
assert.equal(parsed.socketPath, "/tmp/agent.123");
|
||||
assert.equal(parsed.agentPid, 9999);
|
||||
});
|
||||
|
||||
test("parseAgentStdout preserves spaces in Unix and Windows socket paths", () => {
|
||||
for (const socket of ["/tmp/profile name/agent.sock", "C:\\Users\\First Last\\agent.sock"]) {
|
||||
assert.equal(parseAgentStdout(`SSH_AUTH_SOCK=${socket}; export SSH_AUTH_SOCK;`).socketPath, socket);
|
||||
assert.equal(parseAgentStdout(`SSH_AUTH_SOCK='${socket}'; export SSH_AUTH_SOCK;`).socketPath, socket);
|
||||
}
|
||||
});
|
||||
|
||||
test("getTempBase uses Netcatty managed temp dir (no os.tmpdir fallback)", () => {
|
||||
const tempDirBridge = require("./tempDirBridge.cjs");
|
||||
const managed = tempDirBridge.getTempDir();
|
||||
assert.equal(getTempBase(), managed);
|
||||
assert.match(managed, /Netcatty/i);
|
||||
});
|
||||
|
||||
test("release without acquire is safe", () => {
|
||||
releaseFidoAgent();
|
||||
releaseFidoAgent();
|
||||
assert.equal(getActiveFidoAgentSocket(), null);
|
||||
assert.equal(isAgentLive(), false);
|
||||
shutdownFidoAgentSubsystem();
|
||||
});
|
||||
|
||||
async function withManagedTemp(run) {
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
const tempDirBridge = require("./tempDirBridge.cjs");
|
||||
const managedTemp = fs.mkdtempSync(path.join(tempDirBridge.getTempDir(), "fa-"));
|
||||
const originalGetTempDir = tempDirBridge.getTempDir;
|
||||
tempDirBridge.getTempDir = () => managedTemp;
|
||||
try {
|
||||
return await run(managedTemp);
|
||||
} finally {
|
||||
tempDirBridge.getTempDir = originalGetTempDir;
|
||||
shutdownFidoAgentSubsystem();
|
||||
fs.rmSync(managedTemp, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
test("acquireFidoAgent on win32 starts a prompt-capable child agent when -a works", async () => {
|
||||
await withManagedTemp(async () => {
|
||||
shutdownFidoAgentSubsystem();
|
||||
const pipe = "\\\\.\\pipe\\netcatty-fido-agent-testown";
|
||||
const agent = await acquireFidoAgent({
|
||||
platform: "win32",
|
||||
resolveWebContents: () => null,
|
||||
env: { PATH: process.env.PATH || "", NETCATTY_SSH_AGENT_PATH: "/bin/true" },
|
||||
execFile: async (_bin, args, opts) => {
|
||||
assert.ok(opts?.env?.SSH_ASKPASS, "child agent must inherit askpass");
|
||||
assert.equal(opts?.env?.NETCATTY_FIDO_ASKPASS_LEASE, undefined);
|
||||
assert.ok(Array.isArray(args) && args.includes("-a"));
|
||||
return {
|
||||
stdout: `SSH_AUTH_SOCK=${pipe}; export SSH_AUTH_SOCK;\nSSH_AGENT_PID=4242; export SSH_AGENT_PID;\n`,
|
||||
};
|
||||
},
|
||||
});
|
||||
assert.equal(agent.socketPath, pipe);
|
||||
assert.equal(agent.owned, true);
|
||||
assert.ok(agent.askpassEnv?.SSH_ASKPASS);
|
||||
releaseFidoAgent(agent.generation);
|
||||
});
|
||||
});
|
||||
|
||||
test("acquireFidoAgent on win32 falls back to system pipe when child agent cannot start", async () => {
|
||||
await withManagedTemp(async () => {
|
||||
shutdownFidoAgentSubsystem();
|
||||
const agent = await acquireFidoAgent({
|
||||
platform: "win32",
|
||||
resolveWebContents: () => null,
|
||||
env: { PATH: process.env.PATH || "", NETCATTY_SSH_AGENT_PATH: "/bin/true" },
|
||||
execFile: async () => {
|
||||
throw new Error("ssh-agent -a unsupported");
|
||||
},
|
||||
});
|
||||
assert.equal(agent.socketPath, "\\\\.\\pipe\\openssh-ssh-agent");
|
||||
assert.equal(agent.owned, false);
|
||||
assert.ok(agent.askpassEnv?.SSH_ASKPASS);
|
||||
releaseFidoAgent(agent.generation);
|
||||
});
|
||||
});
|
||||
|
||||
test("acquireFidoAgent on win32 does not claim ownership of the system service pipe", async () => {
|
||||
await withManagedTemp(async () => {
|
||||
shutdownFidoAgentSubsystem();
|
||||
const systemPipe = "\\\\.\\pipe\\openssh-ssh-agent";
|
||||
const agent = await acquireFidoAgent({
|
||||
platform: "win32",
|
||||
resolveWebContents: () => null,
|
||||
env: { PATH: process.env.PATH || "", NETCATTY_SSH_AGENT_PATH: "/bin/true" },
|
||||
execFile: async () => ({
|
||||
stdout: `SSH_AUTH_SOCK=${systemPipe}; export SSH_AUTH_SOCK;\n`,
|
||||
}),
|
||||
});
|
||||
assert.equal(agent.socketPath, systemPipe);
|
||||
assert.equal(agent.owned, false);
|
||||
releaseFidoAgent(agent.generation);
|
||||
});
|
||||
});
|
||||
|
||||
test("acquireFidoAgent releases askpass lease when agent start fails", async () => {
|
||||
shutdownFidoAgentSubsystem();
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
const tempDirBridge = require("./tempDirBridge.cjs");
|
||||
const { getAskpassLeaseCountForTests } = require("./fidoAskpass.cjs");
|
||||
const managedTemp = fs.mkdtempSync(path.join(tempDirBridge.getTempDir(), "fa-"));
|
||||
const originalGetTempDir = tempDirBridge.getTempDir;
|
||||
tempDirBridge.getTempDir = () => managedTemp;
|
||||
const before = getAskpassLeaseCountForTests();
|
||||
try {
|
||||
await assert.rejects(
|
||||
() => acquireFidoAgent({
|
||||
platform: "linux",
|
||||
resolveWebContents: () => ({ id: "fail-start" }),
|
||||
env: { PATH: process.env.PATH || "/usr/bin:/bin", NETCATTY_SSH_AGENT_PATH: "/bin/true" },
|
||||
execFile: async () => {
|
||||
throw new Error("ssh-agent missing");
|
||||
},
|
||||
}),
|
||||
(error) => error?.code === "ERR_FIDO_AGENT_START",
|
||||
);
|
||||
assert.equal(getAskpassLeaseCountForTests(), before);
|
||||
} finally {
|
||||
tempDirBridge.getTempDir = originalGetTempDir;
|
||||
shutdownFidoAgentSubsystem();
|
||||
fs.rmSync(managedTemp, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("askpassEnvForAgentProcess strips caller-bound askpass lease", () => {
|
||||
const { askpassEnvForAgentProcess } = require("./fidoAgentManager.cjs");
|
||||
const stripped = askpassEnvForAgentProcess({
|
||||
SSH_ASKPASS: "/tmp/askpass.sh",
|
||||
SSH_ASKPASS_REQUIRE: "force",
|
||||
NETCATTY_FIDO_ASKPASS_SOCK: "/tmp/askpass.sock",
|
||||
NETCATTY_FIDO_ASKPASS_LEASE: "starter-lease",
|
||||
});
|
||||
assert.equal(stripped.SSH_ASKPASS, "/tmp/askpass.sh");
|
||||
assert.equal(stripped.NETCATTY_FIDO_ASKPASS_SOCK, "/tmp/askpass.sock");
|
||||
assert.equal(stripped.SSH_ASKPASS_REQUIRE, "force");
|
||||
assert.equal("NETCATTY_FIDO_ASKPASS_LEASE" in stripped, false);
|
||||
});
|
||||
|
||||
test("ssh-agent spawn env omits caller-bound askpass lease", async () => {
|
||||
shutdownFidoAgentSubsystem();
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
const tempDirBridge = require("./tempDirBridge.cjs");
|
||||
const { releaseFidoAskpassLease } = require("./fidoAskpass.cjs");
|
||||
const managedTemp = fs.mkdtempSync(path.join(tempDirBridge.getTempDir(), "fa-"));
|
||||
const originalGetTempDir = tempDirBridge.getTempDir;
|
||||
tempDirBridge.getTempDir = () => managedTemp;
|
||||
const sockPath = path.join(managedTemp, "agent.sock");
|
||||
fs.writeFileSync(sockPath, "");
|
||||
/** @type {Record<string, string>|null} */
|
||||
let spawnEnv = null;
|
||||
|
||||
try {
|
||||
const acquired = await acquireFidoAgent({
|
||||
platform: "linux",
|
||||
resolveWebContents: () => ({ id: "starter" }),
|
||||
env: { PATH: process.env.PATH || "/usr/bin:/bin", NETCATTY_SSH_AGENT_PATH: "/bin/true" },
|
||||
execFile: async (_bin, _args, opts) => {
|
||||
spawnEnv = opts?.env || null;
|
||||
return {
|
||||
stdout: `SSH_AUTH_SOCK=${sockPath}; export SSH_AUTH_SOCK;\n`,
|
||||
};
|
||||
},
|
||||
});
|
||||
assert.ok(spawnEnv);
|
||||
assert.ok(spawnEnv.SSH_ASKPASS);
|
||||
assert.equal(spawnEnv.NETCATTY_FIDO_ASKPASS_LEASE, undefined);
|
||||
assert.ok(acquired.askpassEnv.NETCATTY_FIDO_ASKPASS_LEASE);
|
||||
releaseFidoAskpassLease(acquired.askpassEnv.NETCATTY_FIDO_ASKPASS_LEASE);
|
||||
releaseFidoAgent(acquired.generation);
|
||||
} finally {
|
||||
tempDirBridge.getTempDir = originalGetTempDir;
|
||||
shutdownFidoAgentSubsystem();
|
||||
fs.rmSync(managedTemp, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("concurrent acquireFidoAgent returns a fresh askpass lease per caller", async () => {
|
||||
await withManagedTemp(async (managedTemp) => {
|
||||
shutdownFidoAgentSubsystem();
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
const { releaseFidoAskpassLease } = require("./fidoAskpass.cjs");
|
||||
const sockPath = path.join(managedTemp, "agent.sock");
|
||||
fs.writeFileSync(sockPath, "");
|
||||
let releaseStart;
|
||||
const startGate = new Promise((resolve) => { releaseStart = resolve; });
|
||||
|
||||
const first = acquireFidoAgent({
|
||||
platform: "linux",
|
||||
resolveWebContents: () => ({ id: "first" }),
|
||||
env: { PATH: process.env.PATH || "/usr/bin:/bin", NETCATTY_SSH_AGENT_PATH: "/bin/true" },
|
||||
execFile: async () => {
|
||||
releaseStart();
|
||||
await new Promise((r) => setTimeout(r, 80));
|
||||
return {
|
||||
stdout: `SSH_AUTH_SOCK=${sockPath}; export SSH_AUTH_SOCK;\n`,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
await startGate;
|
||||
const second = acquireFidoAgent({
|
||||
platform: "linux",
|
||||
resolveWebContents: () => ({ id: "second" }),
|
||||
execFile: async () => {
|
||||
throw new Error("second caller must await startingPromise");
|
||||
},
|
||||
});
|
||||
|
||||
const [a, b] = await Promise.all([first, second]);
|
||||
assert.equal(a.socketPath, sockPath);
|
||||
assert.equal(b.socketPath, sockPath);
|
||||
assert.notEqual(
|
||||
a.askpassEnv.NETCATTY_FIDO_ASKPASS_LEASE,
|
||||
b.askpassEnv.NETCATTY_FIDO_ASKPASS_LEASE,
|
||||
);
|
||||
releaseFidoAskpassLease(a.askpassEnv.NETCATTY_FIDO_ASKPASS_LEASE);
|
||||
releaseFidoAskpassLease(b.askpassEnv.NETCATTY_FIDO_ASKPASS_LEASE);
|
||||
releaseFidoAgent(a.generation);
|
||||
releaseFidoAgent(b.generation);
|
||||
});
|
||||
});
|
||||
|
||||
test("stale releaseFidoAgent ignores a newer agent generation", async () => {
|
||||
shutdownFidoAgentSubsystem();
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
const tempDirBridge = require("./tempDirBridge.cjs");
|
||||
const { releaseFidoAskpassLease } = require("./fidoAskpass.cjs");
|
||||
const managedTemp = fs.mkdtempSync(path.join(tempDirBridge.getTempDir(), "fa-"));
|
||||
const originalGetTempDir = tempDirBridge.getTempDir;
|
||||
tempDirBridge.getTempDir = () => managedTemp;
|
||||
const firstSock = path.join(managedTemp, "agent1.sock");
|
||||
const secondSock = path.join(managedTemp, "agent2.sock");
|
||||
fs.writeFileSync(firstSock, "");
|
||||
fs.writeFileSync(secondSock, "");
|
||||
|
||||
try {
|
||||
const first = await acquireFidoAgent({
|
||||
platform: "linux",
|
||||
resolveWebContents: () => ({ id: "gen-first" }),
|
||||
env: { PATH: process.env.PATH || "/usr/bin:/bin", NETCATTY_SSH_AGENT_PATH: "/bin/true" },
|
||||
execFile: async () => ({
|
||||
stdout: `SSH_AUTH_SOCK=${firstSock}; export SSH_AUTH_SOCK;\n`,
|
||||
}),
|
||||
});
|
||||
assert.equal(getActiveFidoAgentSocket(), firstSock);
|
||||
|
||||
// Simulate the managed agent dying while a connection still holds a release.
|
||||
fs.rmSync(firstSock, { force: true });
|
||||
assert.equal(isAgentLive(), false);
|
||||
|
||||
const second = await acquireFidoAgent({
|
||||
platform: "linux",
|
||||
resolveWebContents: () => ({ id: "gen-second" }),
|
||||
env: { PATH: process.env.PATH || "/usr/bin:/bin", NETCATTY_SSH_AGENT_PATH: "/bin/true" },
|
||||
execFile: async () => ({
|
||||
stdout: `SSH_AUTH_SOCK=${secondSock}; export SSH_AUTH_SOCK;\n`,
|
||||
}),
|
||||
});
|
||||
assert.notEqual(first.generation, second.generation);
|
||||
assert.equal(getActiveFidoAgentSocket(), secondSock);
|
||||
|
||||
// Late close of the old connection must not kill the replacement agent.
|
||||
releaseFidoAgent(first.generation);
|
||||
assert.equal(getActiveFidoAgentSocket(), secondSock);
|
||||
assert.equal(isAgentLive(), true);
|
||||
|
||||
releaseFidoAskpassLease(first.askpassEnv.NETCATTY_FIDO_ASKPASS_LEASE);
|
||||
releaseFidoAskpassLease(second.askpassEnv.NETCATTY_FIDO_ASKPASS_LEASE);
|
||||
releaseFidoAgent(second.generation);
|
||||
assert.equal(getActiveFidoAgentSocket(), null);
|
||||
} finally {
|
||||
tempDirBridge.getTempDir = originalGetTempDir;
|
||||
shutdownFidoAgentSubsystem();
|
||||
fs.rmSync(managedTemp, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,537 @@
|
||||
"use strict";
|
||||
|
||||
/**
|
||||
* SSH_ASKPASS bridge for FIDO2 (ssh-keygen / ssh-add / ssh-sk-helper).
|
||||
*
|
||||
* OpenSSH invokes SSH_ASKPASS as a subprocess with the prompt text as argv.
|
||||
* The helper connects to a Netcatty-owned IPC socket; main process shows a
|
||||
* native PIN/touch modal and returns the response on stdout of the helper.
|
||||
*/
|
||||
|
||||
const fs = require("node:fs");
|
||||
const net = require("node:net");
|
||||
const path = require("node:path");
|
||||
const { randomUUID } = require("node:crypto");
|
||||
const fidoPromptHandler = require("./fidoPromptHandler.cjs");
|
||||
|
||||
const FIDO_ASKPASS_SCRIPT = String.raw`#!/usr/bin/env node
|
||||
"use strict";
|
||||
const net = require("node:net");
|
||||
const fs = require("node:fs");
|
||||
|
||||
const sockPath = process.env.NETCATTY_FIDO_ASKPASS_SOCK;
|
||||
const leaseId = process.env.NETCATTY_FIDO_ASKPASS_LEASE || "";
|
||||
const prompt = process.argv.slice(2).join(" ") || process.env.SSH_ASKPASS_PROMPT || "";
|
||||
|
||||
function fail(code) {
|
||||
process.exit(code == null ? 1 : code);
|
||||
}
|
||||
|
||||
if (!sockPath) fail(1);
|
||||
|
||||
const payload = JSON.stringify({ prompt, type: "askpass", leaseId,
|
||||
mode: process.env.SSH_ASKPASS_PROMPT || "" }) + "\n";
|
||||
const client = net.createConnection(sockPath);
|
||||
|
||||
let buf = "";
|
||||
let settled = false;
|
||||
|
||||
function finish(ok, text) {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
try { client.end(); } catch { /* ignore */ }
|
||||
if (!ok) fail(1);
|
||||
process.stdout.write(text == null ? "" : String(text));
|
||||
process.stdout.write("\n");
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
client.setEncoding("utf8");
|
||||
client.on("connect", () => {
|
||||
client.write(payload);
|
||||
});
|
||||
client.on("data", (chunk) => {
|
||||
buf += chunk;
|
||||
const nl = buf.indexOf("\n");
|
||||
if (nl === -1) return;
|
||||
let msg;
|
||||
try {
|
||||
msg = JSON.parse(buf.slice(0, nl));
|
||||
} catch {
|
||||
finish(false);
|
||||
return;
|
||||
}
|
||||
if (msg && msg.ok === true) finish(true, msg.response || "");
|
||||
else finish(false);
|
||||
});
|
||||
client.on("error", () => finish(false));
|
||||
client.on("end", () => {
|
||||
if (!settled) finish(false);
|
||||
});
|
||||
setTimeout(() => finish(false), 170000);
|
||||
`;
|
||||
|
||||
/** @type {net.Server|null} */
|
||||
let askpassServer = null;
|
||||
/** @type {string|null} */
|
||||
let askpassSocketPath = null;
|
||||
/** @type {string|null} */
|
||||
let askpassScriptPath = null;
|
||||
/** @type {string|null} */
|
||||
let askpassWrapperPath = null;
|
||||
/** @type {(() => import("electron").WebContents|null)|null} */
|
||||
let resolveWebContents = null;
|
||||
/** @type {Map<string, () => import("electron").WebContents|null>} */
|
||||
const askpassLeases = new Map();
|
||||
/**
|
||||
* Last resolver that successfully handled a caller-bound (leased) askpass
|
||||
* prompt. Agent-spawned ssh-sk-helper has no lease after we strip the starter
|
||||
* lease from the shared agent env; route those prompts to this signing window
|
||||
* instead of the sticky last-acquire global resolver (wrong under multi-window
|
||||
* / terminal-worker where BrowserWindow focus is unavailable).
|
||||
* @type {(() => import("electron").WebContents|null)|null}
|
||||
*/
|
||||
let lastLeasedSigningResolver = null;
|
||||
/** @type {string|null} Lease id that last marked lastLeasedSigningResolver. */
|
||||
let lastLeasedSigningLeaseId = null;
|
||||
let activeSigningLeaseId = null;
|
||||
const signingQueue = [];
|
||||
let helperTimeoutMs = 30000;
|
||||
|
||||
function setHelperTimeoutForTests(value) {
|
||||
if (value != null && (!Number.isFinite(value) || value <= 0)) throw new Error("Invalid FIDO helper timeout");
|
||||
helperTimeoutMs = value == null ? 30000 : value;
|
||||
}
|
||||
|
||||
function observeSkHelper(socket, entry, initial = "") {
|
||||
if (!entry) { socket.end('{"ack":true}\n'); return; }
|
||||
if (entry.controller.signal.aborted) { socket.end('{"cancel":true}\n'); return; }
|
||||
let buffer = initial;
|
||||
const timer = setTimeout(() => {
|
||||
entry.timedOut = true;
|
||||
if (!socket.destroyed) socket.write('{"cancel":true}\n');
|
||||
}, helperTimeoutMs);
|
||||
const cancelHelper = () => {
|
||||
if (!socket.destroyed) socket.write('{"cancel":true}\n');
|
||||
};
|
||||
const onAbort = cancelHelper;
|
||||
entry.controller.signal.addEventListener("abort", onAbort, { once: true });
|
||||
const consume = chunk => {
|
||||
buffer += chunk;
|
||||
const nl = buffer.indexOf("\n");
|
||||
if (nl === -1) return;
|
||||
let result;
|
||||
try { result = JSON.parse(buffer.slice(0, nl)); } catch { socket.destroy(); return; }
|
||||
if (result.phase !== "result") return;
|
||||
clearTimeout(timer);
|
||||
entry.helperError = Number.isInteger(result.errorCode) ? result.errorCode : null;
|
||||
socket.end('{"ack":true}\n');
|
||||
};
|
||||
socket.on("data", consume);
|
||||
socket.once("close", () => {
|
||||
clearTimeout(timer);
|
||||
entry.controller.signal.removeEventListener("abort", onAbort);
|
||||
});
|
||||
if (buffer) consume("");
|
||||
}
|
||||
|
||||
// A shared OpenSSH agent cannot pass a per-request lease to its helper.
|
||||
// Serialize hardware signatures and bind each prompt to its calling window
|
||||
// until that signature's callback settles.
|
||||
function runWithFidoSigningLease(leaseId, operation) {
|
||||
signingQueue.push({ leaseId, operation, controller: new AbortController() });
|
||||
if (signingQueue.length > 1) return;
|
||||
const next = () => {
|
||||
const entry = signingQueue[0];
|
||||
if (!entry) return;
|
||||
activeSigningLeaseId = entry.leaseId;
|
||||
let finished = false;
|
||||
entry.operation(() => {
|
||||
if (finished) return;
|
||||
finished = true;
|
||||
entry.attemptController?.abort();
|
||||
entry.controller.abort();
|
||||
activeSigningLeaseId = null;
|
||||
signingQueue.shift();
|
||||
if (signingQueue.length) queueMicrotask(next);
|
||||
}, entry);
|
||||
};
|
||||
next();
|
||||
}
|
||||
|
||||
function getTempBase() {
|
||||
const tempDirBridge = require("./tempDirBridge.cjs");
|
||||
if (typeof tempDirBridge.getTempDir !== "function") {
|
||||
throw new Error("FIDO askpass requires Netcatty temp directory (tempDirBridge unavailable).");
|
||||
}
|
||||
return tempDirBridge.getTempDir();
|
||||
}
|
||||
|
||||
function isWindowsNamedPipePath(socketPath) {
|
||||
return typeof socketPath === "string"
|
||||
&& /^\\\\[.,]\\pipe\\/i.test(socketPath);
|
||||
}
|
||||
|
||||
/**
|
||||
* Askpass IPC address. Windows Node `net.Server` only binds named pipes under
|
||||
* `\\.\pipe\...` — a filesystem `askpass.sock` path fails to listen.
|
||||
* @param {string} baseDir
|
||||
* @param {NodeJS.Platform} [platform]
|
||||
*/
|
||||
function resolveFidoAskpassSocketPath(baseDir, platform = process.platform) {
|
||||
if (platform === "win32") {
|
||||
return `\\\\.\\pipe\\netcatty-fido-askpass-${randomUUID().slice(0, 8)}`;
|
||||
}
|
||||
return path.posix.join(baseDir, "askpass.sock");
|
||||
}
|
||||
|
||||
function writeSecureFile(filePath, contents, mode = 0o700) {
|
||||
fs.writeFileSync(filePath, contents, { mode });
|
||||
try {
|
||||
fs.chmodSync(filePath, mode);
|
||||
} catch {
|
||||
// Windows may ignore mode
|
||||
}
|
||||
}
|
||||
|
||||
function defaultResolveWebContents() {
|
||||
try {
|
||||
const { BrowserWindow } = require("electron");
|
||||
const focused = BrowserWindow.getFocusedWindow();
|
||||
if (focused && !focused.isDestroyed()) return focused.webContents;
|
||||
for (const win of BrowserWindow.getAllWindows()) {
|
||||
if (!win.isDestroyed() && win.webContents && !win.webContents.isDestroyed()) {
|
||||
return win.webContents;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// not in electron
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function liveSenderFromResolver(resolver) {
|
||||
if (typeof resolver !== "function") return null;
|
||||
try {
|
||||
const sender = resolver();
|
||||
if (sender && !sender.isDestroyed?.()) return sender;
|
||||
} catch {
|
||||
// ignore dead resolvers
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the WebContents for agent-spawned (leaseless) askpass prompts.
|
||||
* Prefer the window that most recently completed a leased signing prompt, then
|
||||
* focus / any open window, then the sticky global fallback.
|
||||
*/
|
||||
function resolveSharedAgentPromptSender() {
|
||||
// An active but released/dead caller must not fall through to another window.
|
||||
if (activeSigningLeaseId) {
|
||||
return liveSenderFromResolver(askpassLeases.get(activeSigningLeaseId));
|
||||
}
|
||||
const fromLastLease = liveSenderFromResolver(lastLeasedSigningResolver);
|
||||
if (fromLastLease) return fromLastLease;
|
||||
|
||||
for (const resolver of askpassLeases.values()) {
|
||||
const sender = liveSenderFromResolver(resolver);
|
||||
if (sender) return sender;
|
||||
}
|
||||
|
||||
return defaultResolveWebContents()
|
||||
|| liveSenderFromResolver(resolveWebContents);
|
||||
}
|
||||
|
||||
function handleAskpassClient(socket, platform = process.platform) {
|
||||
let buf = "";
|
||||
let handled = false;
|
||||
const controller = new AbortController();
|
||||
// OpenSSH kills its notification helper as soon as the token is touched.
|
||||
// Dismiss that prompt even when the renderer has not clicked Continue.
|
||||
socket.once("close", () => controller.abort());
|
||||
socket.on("error", () => controller.abort());
|
||||
socket.setEncoding("utf8");
|
||||
socket.on("data", async (chunk) => {
|
||||
if (handled) return;
|
||||
buf += chunk;
|
||||
const nl = buf.indexOf("\n");
|
||||
if (nl === -1) return;
|
||||
handled = true;
|
||||
let msg;
|
||||
try {
|
||||
msg = JSON.parse(buf.slice(0, nl));
|
||||
} catch {
|
||||
socket.end(`${JSON.stringify({ ok: false, error: "bad_json" })}\n`);
|
||||
return;
|
||||
}
|
||||
if (msg?.type === "sk-helper") {
|
||||
observeSkHelper(socket, signingQueue[0], buf.slice(nl + 1));
|
||||
return;
|
||||
}
|
||||
const prompt = String(msg?.prompt || "");
|
||||
// OpenSSH notify_start() uses mode=none: hardware completion, not a UI
|
||||
// answer, authorizes the signature. Exiting this helper only hides the hint.
|
||||
const notificationOnly = msg?.mode === "none";
|
||||
const kind = notificationOnly ? "touch"
|
||||
: msg?.mode === "confirm" ? "confirm"
|
||||
: fidoPromptHandler.classifyAskpassPrompt(prompt);
|
||||
const leaseId = typeof msg?.leaseId === "string" ? msg.leaseId : "";
|
||||
const leaseResolver = leaseId ? askpassLeases.get(leaseId) : null;
|
||||
// Caller-bound leases (ssh-add) win and mark the active signing window.
|
||||
// Agent-spawned ssh-sk-helper has no lease (shared agent must not inherit a
|
||||
// starter lease) — route to that signing window, not the last acquire.
|
||||
// Tag leaseless prompts with the last signing lease so teardown can cancel.
|
||||
let sender = null;
|
||||
/** @type {string} */
|
||||
let ownerLeaseId = "";
|
||||
if (leaseResolver) {
|
||||
sender = liveSenderFromResolver(leaseResolver);
|
||||
if (sender) {
|
||||
lastLeasedSigningResolver = leaseResolver;
|
||||
lastLeasedSigningLeaseId = leaseId;
|
||||
ownerLeaseId = leaseId;
|
||||
}
|
||||
} else {
|
||||
sender = resolveSharedAgentPromptSender();
|
||||
ownerLeaseId = activeSigningLeaseId || lastLeasedSigningLeaseId || "";
|
||||
}
|
||||
if (!sender) {
|
||||
socket.end(`${JSON.stringify({ ok: false, error: "no_window" })}\n`);
|
||||
return;
|
||||
}
|
||||
const signing = signingQueue[0]?.leaseId === ownerLeaseId ? signingQueue[0] : null;
|
||||
try {
|
||||
const detachedNotification = notificationOnly && platform === "win32";
|
||||
if (kind === "pin" && platform === "win32") {
|
||||
fidoPromptHandler.cancelFidoPromptRequestsForLease(ownerLeaseId, "pin-requested", true);
|
||||
}
|
||||
const pending = fidoPromptHandler.requestFidoPrompt(sender, {
|
||||
kind,
|
||||
notificationOnly,
|
||||
message: prompt,
|
||||
keyName: signing?.keyName || "",
|
||||
leaseId: ownerLeaseId,
|
||||
signal: detachedNotification
|
||||
? (signing?.attemptController || signing?.controller)?.signal : controller.signal,
|
||||
});
|
||||
if (detachedNotification) {
|
||||
// MSYS cannot reliably terminate a native Node notification helper.
|
||||
// Acknowledge this non-authorizing notification immediately; retain the
|
||||
// hint under the signing attempt (or key-generation lease) instead.
|
||||
void pending;
|
||||
socket.end('{"ok":true,"response":""}\n');
|
||||
return;
|
||||
}
|
||||
const result = await pending;
|
||||
if (socket.destroyed) return;
|
||||
if (!result || result.cancelled) {
|
||||
if (kind === "pin" && signing) signing.cancelled = true;
|
||||
socket.end(`${JSON.stringify({ ok: false, error: "cancelled" })}\n`);
|
||||
return;
|
||||
}
|
||||
// Touch/confirm: empty string is fine; PIN: return entered secret.
|
||||
const response = kind === "pin" ? (result.response || "") : "";
|
||||
if (kind === "pin" && signing) signing.pinSubmitted = true;
|
||||
if (kind === "pin" && response
|
||||
&& (platform === "win32" || /user presence|touch|tap/i.test(prompt))
|
||||
&& ((signing?.leaseId === ownerLeaseId && !signing.controller.signal.aborted)
|
||||
|| (platform === "win32" && ownerLeaseId))) {
|
||||
// ssh-agent ends its initial notification before asking for a PIN and
|
||||
// does not restart it on retry. Keep a device-only waiting hint visible
|
||||
// after PIN submission until the actual signature callback completes.
|
||||
void fidoPromptHandler.requestFidoPrompt(sender, {
|
||||
kind: "touch", notificationOnly: true, message: prompt, keyName: signing?.keyName || "",
|
||||
leaseId: ownerLeaseId, signal: (signing?.attemptController || signing?.controller)?.signal,
|
||||
});
|
||||
}
|
||||
socket.end(`${JSON.stringify({ ok: true, response })}\n`);
|
||||
} catch (err) {
|
||||
socket.end(`${JSON.stringify({
|
||||
ok: false,
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
})}\n`);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure askpass server + helper scripts exist.
|
||||
* @param {{ resolveWebContents?: () => import("electron").WebContents|null }} [options]
|
||||
*/
|
||||
function ensureFidoAskpass(options = {}) {
|
||||
if (options.resolveWebContents) resolveWebContents = options.resolveWebContents;
|
||||
|
||||
if (askpassServer && askpassSocketPath && askpassWrapperPath) {
|
||||
return {
|
||||
socketPath: askpassSocketPath,
|
||||
scriptPath: askpassScriptPath,
|
||||
wrapperPath: askpassWrapperPath,
|
||||
};
|
||||
}
|
||||
|
||||
const base = path.join(getTempBase(), `netcatty-fido-askpass-${randomUUID().slice(0, 8)}`);
|
||||
fs.mkdirSync(base, { recursive: true, mode: 0o700 });
|
||||
askpassSocketPath = resolveFidoAskpassSocketPath(base);
|
||||
askpassScriptPath = path.join(base, "netcatty-fido-askpass.cjs");
|
||||
writeSecureFile(askpassScriptPath, FIDO_ASKPASS_SCRIPT, 0o700);
|
||||
|
||||
const posixWrapperPath = path.join(base, "netcatty-fido-askpass.sh");
|
||||
const shellQuote = value => `'${value.replace(/'/g, "'\\''")}'`;
|
||||
const shellPath = value => process.platform === "win32" ? value.replace(/\\/g, "/") : value;
|
||||
writeSecureFile(posixWrapperPath,
|
||||
`#!/bin/sh\nELECTRON_RUN_AS_NODE=1 exec ${shellQuote(shellPath(process.execPath))} ${shellQuote(shellPath(askpassScriptPath))} "$@"\n`,
|
||||
0o700);
|
||||
if (process.platform === "win32") {
|
||||
askpassWrapperPath = path.join(base, "netcatty-fido-askpass.cmd");
|
||||
writeSecureFile(
|
||||
askpassWrapperPath,
|
||||
`@echo off\r\nset ELECTRON_RUN_AS_NODE=1\r\n"${process.execPath.replace(/"/g, '""')}" "${askpassScriptPath.replace(/"/g, '""')}" %*\r\n`,
|
||||
0o700,
|
||||
);
|
||||
} else {
|
||||
askpassWrapperPath = posixWrapperPath;
|
||||
}
|
||||
|
||||
if (!isWindowsNamedPipePath(askpassSocketPath) && fs.existsSync(askpassSocketPath)) {
|
||||
try { fs.unlinkSync(askpassSocketPath); } catch { /* ignore */ }
|
||||
}
|
||||
|
||||
askpassServer = net.createServer(handleAskpassClient);
|
||||
askpassServer.on("error", (err) => {
|
||||
console.error("[FidoAskpass] server error:", err instanceof Error ? err.message : err);
|
||||
});
|
||||
askpassServer.listen(askpassSocketPath);
|
||||
if (!isWindowsNamedPipePath(askpassSocketPath)) {
|
||||
try {
|
||||
fs.chmodSync(askpassSocketPath, 0o600);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
socketPath: askpassSocketPath,
|
||||
scriptPath: askpassScriptPath,
|
||||
wrapperPath: askpassWrapperPath,
|
||||
};
|
||||
}
|
||||
|
||||
function isWindowsPosixSsh(binaryPath, platform = process.platform, env = process.env, exists = fs.existsSync) {
|
||||
if (platform !== "win32" || !binaryPath) return false;
|
||||
const binary = /\.exe$/i.test(binaryPath) ? binaryPath : `${binaryPath}.exe`;
|
||||
const candidates = /[\\/]/.test(binaryPath) ? [binary]
|
||||
: (env.PATH || env.Path || "").split(";").map(dir => path.win32.join(dir, binary));
|
||||
for (const candidate of candidates) {
|
||||
if (!exists(candidate)) continue;
|
||||
const dir = path.win32.dirname(candidate);
|
||||
if (exists(path.win32.join(dir, "msys-2.0.dll")) || exists(path.win32.join(dir, "cygwin1.dll"))) {
|
||||
return true;
|
||||
}
|
||||
break;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function selectFidoAskpassWrapper(wrapperPath, binaryPath, platform = process.platform, env = process.env, exists = fs.existsSync) {
|
||||
// MSYS uses its own shell/shebang execution so spaces in helper paths survive.
|
||||
return isWindowsPosixSsh(binaryPath, platform, env, exists)
|
||||
? wrapperPath.replace(/\.cmd$/i, ".sh").replace(/\\/g, "/") : wrapperPath;
|
||||
}
|
||||
|
||||
/**
|
||||
* Environment for ssh-agent / ssh-add / ssh-keygen so PIN/touch use Netcatty UI.
|
||||
* @param {{ resolveWebContents?: () => import("electron").WebContents|null, sshBinaryPath?: string, env?: NodeJS.ProcessEnv }} [options]
|
||||
*/
|
||||
function buildFidoAskpassEnv(options = {}) {
|
||||
const artifacts = ensureFidoAskpass(options);
|
||||
const leaseId = randomUUID().slice(0, 12);
|
||||
if (typeof options.resolveWebContents === "function") {
|
||||
askpassLeases.set(leaseId, options.resolveWebContents);
|
||||
}
|
||||
const env = {
|
||||
SSH_ASKPASS: selectFidoAskpassWrapper(artifacts.wrapperPath, options.sshBinaryPath, process.platform, options.env),
|
||||
SSH_ASKPASS_REQUIRE: "force",
|
||||
NETCATTY_FIDO_ASKPASS_SOCK: artifacts.socketPath,
|
||||
NETCATTY_FIDO_ASKPASS_LEASE: leaseId,
|
||||
// OpenSSH only runs askpass when no TTY unless REQUIRE=force; still set DISPLAY on Linux.
|
||||
...(process.platform === "linux" && !process.env.DISPLAY
|
||||
? { DISPLAY: process.env.DISPLAY || ":0" }
|
||||
: {}),
|
||||
};
|
||||
return env;
|
||||
}
|
||||
|
||||
function releaseFidoAskpassLease(leaseId) {
|
||||
if (typeof leaseId !== "string" || !leaseId) return;
|
||||
const released = askpassLeases.get(leaseId);
|
||||
askpassLeases.delete(leaseId);
|
||||
for (const entry of signingQueue) {
|
||||
if (entry.leaseId === leaseId) entry.controller.abort();
|
||||
}
|
||||
try {
|
||||
fidoPromptHandler.cancelFidoPromptRequestsForLease(leaseId, "lease-released");
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
if (released && lastLeasedSigningResolver === released) {
|
||||
lastLeasedSigningResolver = null;
|
||||
lastLeasedSigningLeaseId = null;
|
||||
for (const [otherLeaseId, resolver] of askpassLeases.entries()) {
|
||||
if (liveSenderFromResolver(resolver)) {
|
||||
lastLeasedSigningResolver = resolver;
|
||||
lastLeasedSigningLeaseId = otherLeaseId;
|
||||
break;
|
||||
}
|
||||
}
|
||||
} else if (lastLeasedSigningLeaseId === leaseId) {
|
||||
lastLeasedSigningLeaseId = null;
|
||||
}
|
||||
}
|
||||
|
||||
function shutdownFidoAskpass() {
|
||||
try {
|
||||
askpassServer?.close();
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
askpassServer = null;
|
||||
askpassSocketPath = null;
|
||||
askpassScriptPath = null;
|
||||
askpassWrapperPath = null;
|
||||
const outstandingLeaseIds = [...askpassLeases.keys()];
|
||||
askpassLeases.clear();
|
||||
lastLeasedSigningResolver = null;
|
||||
lastLeasedSigningLeaseId = null;
|
||||
for (const leaseId of outstandingLeaseIds) {
|
||||
try {
|
||||
fidoPromptHandler.cancelFidoPromptRequestsForLease(leaseId, "askpass-shutdown");
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
ensureFidoAskpass,
|
||||
buildFidoAskpassEnv,
|
||||
runWithFidoSigningLease,
|
||||
setHelperTimeoutForTests,
|
||||
releaseFidoAskpassLease,
|
||||
shutdownFidoAskpass,
|
||||
classifyAskpassPrompt: fidoPromptHandler.classifyAskpassPrompt,
|
||||
resolveFidoAskpassSocketPath,
|
||||
isWindowsNamedPipePath,
|
||||
selectFidoAskpassWrapper,
|
||||
isWindowsPosixSsh,
|
||||
// exposed for tests
|
||||
getTempBase,
|
||||
handleAskpassClient,
|
||||
FIDO_ASKPASS_SCRIPT,
|
||||
setResolveWebContentsForTests(resolver) {
|
||||
resolveWebContents = typeof resolver === "function" ? resolver : null;
|
||||
},
|
||||
getAskpassLeaseCountForTests() {
|
||||
return askpassLeases.size;
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,319 @@
|
||||
"use strict";
|
||||
|
||||
/**
|
||||
* Simulated FIDO askpass verification (no hardware).
|
||||
*
|
||||
* Exercises the OpenSSH SSH_ASKPASS helper → Netcatty IPC socket → prompt
|
||||
* handler → PIN response path that real ssh-sk-helper would trigger.
|
||||
*/
|
||||
|
||||
const test = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
const { spawn } = require("node:child_process");
|
||||
const tempDirBridge = require("./tempDirBridge.cjs");
|
||||
// Keep the managed temp path short: Unix domain sockets reject long sun_path.
|
||||
const managedTemp = fs.mkdtempSync(path.join(tempDirBridge.getTempDir(), "fp 'q$-"));
|
||||
const originalGetTempDir = tempDirBridge.getTempDir;
|
||||
tempDirBridge.getTempDir = () => managedTemp;
|
||||
const {
|
||||
buildFidoAskpassEnv,
|
||||
ensureFidoAskpass,
|
||||
shutdownFidoAskpass,
|
||||
setResolveWebContentsForTests,
|
||||
} = require("./fidoAskpass.cjs");
|
||||
const fidoPromptHandler = require("./fidoPromptHandler.cjs");
|
||||
|
||||
test.after(() => {
|
||||
tempDirBridge.getTempDir = originalGetTempDir;
|
||||
try { fs.rmSync(managedTemp, { recursive: true, force: true }); } catch { /* ignore */ }
|
||||
});
|
||||
|
||||
function runAskpassHelper({ wrapperPath, socketPath, prompt, timeoutMs = 5000, envExtra = {} }) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const child = spawn(wrapperPath, [prompt], {
|
||||
env: {
|
||||
...process.env,
|
||||
ELECTRON_RUN_AS_NODE: "1",
|
||||
NETCATTY_FIDO_ASKPASS_SOCK: socketPath,
|
||||
SSH_ASKPASS_REQUIRE: "force",
|
||||
...envExtra,
|
||||
},
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
let stdout = "";
|
||||
let stderr = "";
|
||||
const timer = setTimeout(() => {
|
||||
child.kill("SIGKILL");
|
||||
reject(new Error("askpass helper timed out"));
|
||||
}, timeoutMs);
|
||||
child.stdout.setEncoding("utf8");
|
||||
child.stderr.setEncoding("utf8");
|
||||
child.stdout.on("data", (chunk) => { stdout += chunk; });
|
||||
child.stderr.on("data", (chunk) => { stderr += chunk; });
|
||||
child.on("error", (err) => {
|
||||
clearTimeout(timer);
|
||||
reject(err);
|
||||
});
|
||||
child.on("close", (code) => {
|
||||
clearTimeout(timer);
|
||||
resolve({ code, stdout, stderr });
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
test("simulated FIDO askpass returns PIN through helper subprocess", async () => {
|
||||
const pending = new Map();
|
||||
const sender = {
|
||||
id: 4242,
|
||||
isDestroyed: () => false,
|
||||
send(_channel, payload) {
|
||||
pending.set(payload.requestId, payload);
|
||||
// Auto-respond like the renderer FidoPromptModal would.
|
||||
queueMicrotask(() => {
|
||||
fidoPromptHandler.handleResponse(
|
||||
{ sender: { id: 4242 } },
|
||||
{ requestId: payload.requestId, response: "sim-pin-1234", cancelled: false },
|
||||
);
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
setResolveWebContentsForTests(() => sender);
|
||||
try {
|
||||
const env = buildFidoAskpassEnv({ resolveWebContents: () => sender });
|
||||
const artifacts = ensureFidoAskpass({ resolveWebContents: () => sender });
|
||||
assert.equal(artifacts.socketPath, env.NETCATTY_FIDO_ASKPASS_SOCK);
|
||||
assert.ok(env.NETCATTY_FIDO_ASKPASS_LEASE);
|
||||
|
||||
// Give the listen() a tick — Node can accept before the event loop settles.
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
|
||||
const result = await runAskpassHelper({
|
||||
wrapperPath: artifacts.wrapperPath,
|
||||
socketPath: artifacts.socketPath,
|
||||
prompt: "Enter PIN for authenticator:",
|
||||
envExtra: { NETCATTY_FIDO_ASKPASS_LEASE: env.NETCATTY_FIDO_ASKPASS_LEASE },
|
||||
});
|
||||
|
||||
assert.equal(result.code, 0, `stderr=${result.stderr}`);
|
||||
assert.equal(result.stdout.trim(), "sim-pin-1234");
|
||||
assert.ok(pending.size >= 1, "expected a prompt request to be delivered");
|
||||
const delivered = [...pending.values()][0];
|
||||
assert.equal(delivered.kind, "pin");
|
||||
} finally {
|
||||
setResolveWebContentsForTests(null);
|
||||
shutdownFidoAskpass();
|
||||
}
|
||||
});
|
||||
|
||||
test("askpass leases route prompts to the originating resolver", async () => {
|
||||
const seen = [];
|
||||
const senderA = {
|
||||
id: 100,
|
||||
isDestroyed: () => false,
|
||||
send(_channel, payload) {
|
||||
seen.push(100);
|
||||
queueMicrotask(() => {
|
||||
fidoPromptHandler.handleResponse(
|
||||
{ sender: { id: 100 } },
|
||||
{ requestId: payload.requestId, response: "pin-a", cancelled: false },
|
||||
);
|
||||
});
|
||||
},
|
||||
};
|
||||
const senderB = {
|
||||
id: 200,
|
||||
isDestroyed: () => false,
|
||||
send(_channel, payload) {
|
||||
seen.push(200);
|
||||
queueMicrotask(() => {
|
||||
fidoPromptHandler.handleResponse(
|
||||
{ sender: { id: 200 } },
|
||||
{ requestId: payload.requestId, response: "pin-b", cancelled: false },
|
||||
);
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
// Last global resolver would be B; lease must still deliver to A.
|
||||
setResolveWebContentsForTests(() => senderB);
|
||||
try {
|
||||
const envA = buildFidoAskpassEnv({ resolveWebContents: () => senderA });
|
||||
buildFidoAskpassEnv({ resolveWebContents: () => senderB });
|
||||
const artifacts = ensureFidoAskpass();
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
|
||||
const result = await runAskpassHelper({
|
||||
wrapperPath: artifacts.wrapperPath,
|
||||
socketPath: artifacts.socketPath,
|
||||
prompt: "Enter PIN for authenticator:",
|
||||
// Override lease to A's env lease id.
|
||||
envExtra: {
|
||||
NETCATTY_FIDO_ASKPASS_LEASE: envA.NETCATTY_FIDO_ASKPASS_LEASE,
|
||||
},
|
||||
});
|
||||
assert.equal(result.code, 0, `stderr=${result.stderr}`);
|
||||
assert.equal(result.stdout.trim(), "pin-a");
|
||||
assert.deepEqual(seen, [100]);
|
||||
} finally {
|
||||
setResolveWebContentsForTests(null);
|
||||
shutdownFidoAskpass();
|
||||
}
|
||||
});
|
||||
|
||||
test("simulated FIDO askpass touch prompt returns empty confirmation", async () => {
|
||||
const sender = {
|
||||
id: 4243,
|
||||
isDestroyed: () => false,
|
||||
send(_channel, payload) {
|
||||
queueMicrotask(() => {
|
||||
fidoPromptHandler.handleResponse(
|
||||
{ sender: { id: 4243 } },
|
||||
{ requestId: payload.requestId, response: "", cancelled: false },
|
||||
);
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
setResolveWebContentsForTests(() => sender);
|
||||
try {
|
||||
const env = buildFidoAskpassEnv({ resolveWebContents: () => sender });
|
||||
const artifacts = ensureFidoAskpass({ resolveWebContents: () => sender });
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
|
||||
const result = await runAskpassHelper({
|
||||
wrapperPath: artifacts.wrapperPath,
|
||||
socketPath: artifacts.socketPath,
|
||||
prompt: "Confirm user presence for key ED25519-SK",
|
||||
envExtra: { NETCATTY_FIDO_ASKPASS_LEASE: env.NETCATTY_FIDO_ASKPASS_LEASE },
|
||||
});
|
||||
|
||||
assert.equal(result.code, 0, `stderr=${result.stderr}`);
|
||||
assert.equal(result.stdout.trim(), "");
|
||||
} finally {
|
||||
setResolveWebContentsForTests(null);
|
||||
shutdownFidoAskpass();
|
||||
}
|
||||
});
|
||||
|
||||
test("leaseless agent prompts route to the last leased signing window", async () => {
|
||||
const seen = [];
|
||||
const senderA = {
|
||||
id: 501,
|
||||
isDestroyed: () => false,
|
||||
send(_channel, payload) {
|
||||
seen.push(501);
|
||||
queueMicrotask(() => {
|
||||
fidoPromptHandler.handleResponse(
|
||||
{ sender: { id: 501 } },
|
||||
{ requestId: payload.requestId, response: "pin-a", cancelled: false },
|
||||
);
|
||||
});
|
||||
},
|
||||
};
|
||||
const senderB = {
|
||||
id: 502,
|
||||
isDestroyed: () => false,
|
||||
send(_channel, payload) {
|
||||
seen.push(502);
|
||||
queueMicrotask(() => {
|
||||
fidoPromptHandler.handleResponse(
|
||||
{ sender: { id: 502 } },
|
||||
{ requestId: payload.requestId, response: "pin-b", cancelled: false },
|
||||
);
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
// Global / last-acquire resolver is B (terminal-worker has no BrowserWindow).
|
||||
setResolveWebContentsForTests(() => senderB);
|
||||
try {
|
||||
const envA = buildFidoAskpassEnv({ resolveWebContents: () => senderA });
|
||||
buildFidoAskpassEnv({ resolveWebContents: () => senderB });
|
||||
const artifacts = ensureFidoAskpass();
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
|
||||
// First: leased ssh-add prompt binds the active signing window to A.
|
||||
const leased = await runAskpassHelper({
|
||||
wrapperPath: artifacts.wrapperPath,
|
||||
socketPath: artifacts.socketPath,
|
||||
prompt: "Enter PIN for authenticator:",
|
||||
envExtra: { NETCATTY_FIDO_ASKPASS_LEASE: envA.NETCATTY_FIDO_ASKPASS_LEASE },
|
||||
});
|
||||
assert.equal(leased.code, 0, `stderr=${leased.stderr}`);
|
||||
assert.equal(leased.stdout.trim(), "pin-a");
|
||||
|
||||
// Then: agent-spawned ssh-sk-helper has no lease; must still reach A, not B.
|
||||
const leaseless = await runAskpassHelper({
|
||||
wrapperPath: artifacts.wrapperPath,
|
||||
socketPath: artifacts.socketPath,
|
||||
prompt: "Enter PIN for authenticator:",
|
||||
envExtra: { NETCATTY_FIDO_ASKPASS_LEASE: "" },
|
||||
});
|
||||
assert.equal(leaseless.code, 0, `stderr=${leaseless.stderr}`);
|
||||
assert.equal(leaseless.stdout.trim(), "pin-a");
|
||||
assert.deepEqual(seen, [501, 501]);
|
||||
} finally {
|
||||
setResolveWebContentsForTests(null);
|
||||
shutdownFidoAskpass();
|
||||
}
|
||||
});
|
||||
|
||||
test("OpenSSH notification helper exit dismisses its pending touch prompt", async () => {
|
||||
let delivered;
|
||||
const requested = new Promise((resolve) => { delivered = resolve; });
|
||||
const notifications = [];
|
||||
const sender = {
|
||||
id: 2823, isDestroyed: () => false,
|
||||
send(channel, payload) {
|
||||
notifications.push({ channel, payload });
|
||||
if (channel === "netcatty:fido-prompt-request") delivered(payload);
|
||||
},
|
||||
};
|
||||
let child;
|
||||
const env = buildFidoAskpassEnv({ resolveWebContents: () => sender });
|
||||
try {
|
||||
child = spawn(env.SSH_ASKPASS, ["Confirm user presence for key ED25519-SK"], {
|
||||
env: { ...process.env, ...env, SSH_ASKPASS_PROMPT: "none" }, stdio: "ignore",
|
||||
});
|
||||
const request = await requested;
|
||||
assert.equal(request.kind, "touch");
|
||||
assert.equal(request.notificationOnly, true);
|
||||
const closed = new Promise((resolve) => child.once("close", resolve));
|
||||
child.kill("SIGTERM");
|
||||
await closed;
|
||||
for (let i = 0; i < 50 && fidoPromptHandler.getRequests().has(request.requestId); i++) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
}
|
||||
assert.equal(fidoPromptHandler.getRequests().has(request.requestId), false);
|
||||
assert.ok(notifications.some(({ channel, payload }) =>
|
||||
channel === "netcatty:fido-prompt-cancelled" && payload.requestId === request.requestId));
|
||||
} finally {
|
||||
child?.kill();
|
||||
require("./fidoAskpass.cjs").releaseFidoAskpassLease(env.NETCATTY_FIDO_ASKPASS_LEASE);
|
||||
shutdownFidoAskpass();
|
||||
}
|
||||
});
|
||||
|
||||
test("late prompts for a released signing lease never reach another window", async () => {
|
||||
let otherPrompts = 0;
|
||||
const owner = { id: 810, isDestroyed: () => false, send() {} };
|
||||
const other = { id: 811, isDestroyed: () => false, send() { otherPrompts++; } };
|
||||
const env = buildFidoAskpassEnv({ resolveWebContents: () => owner });
|
||||
buildFidoAskpassEnv({ resolveWebContents: () => other });
|
||||
const api = require("./fidoAskpass.cjs");
|
||||
let finish;
|
||||
api.runWithFidoSigningLease(env.NETCATTY_FIDO_ASKPASS_LEASE, done => { finish = done; });
|
||||
api.releaseFidoAskpassLease(env.NETCATTY_FIDO_ASKPASS_LEASE);
|
||||
try {
|
||||
const artifacts = ensureFidoAskpass();
|
||||
const result = await runAskpassHelper({
|
||||
wrapperPath: artifacts.wrapperPath, socketPath: artifacts.socketPath,
|
||||
prompt: "Enter PIN for authenticator:", envExtra: { NETCATTY_FIDO_ASKPASS_LEASE: "" },
|
||||
});
|
||||
assert.equal(result.code, 1);
|
||||
assert.equal(otherPrompts, 0);
|
||||
} finally { finish(); shutdownFidoAskpass(); }
|
||||
});
|
||||
@@ -0,0 +1,89 @@
|
||||
"use strict";
|
||||
|
||||
const test = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const {
|
||||
classifyAskpassPrompt,
|
||||
buildFidoAskpassEnv,
|
||||
ensureFidoAskpass,
|
||||
releaseFidoAskpassLease,
|
||||
shutdownFidoAskpass,
|
||||
getTempBase,
|
||||
} = require("./fidoAskpass.cjs");
|
||||
const fidoPromptHandler = require("./fidoPromptHandler.cjs");
|
||||
const fs = require("node:fs");
|
||||
const { EventEmitter } = require("node:events");
|
||||
|
||||
test("buildFidoAskpassEnv creates helper artifacts", () => {
|
||||
const env = buildFidoAskpassEnv();
|
||||
assert.ok(env.SSH_ASKPASS);
|
||||
assert.equal(env.SSH_ASKPASS_REQUIRE, "force");
|
||||
assert.ok(env.NETCATTY_FIDO_ASKPASS_SOCK);
|
||||
assert.ok(fs.existsSync(env.SSH_ASKPASS));
|
||||
const artifacts = ensureFidoAskpass();
|
||||
assert.equal(artifacts.wrapperPath, env.SSH_ASKPASS);
|
||||
shutdownFidoAskpass();
|
||||
});
|
||||
|
||||
test("getTempBase uses Netcatty managed temp dir (no os.tmpdir fallback)", () => {
|
||||
const tempDirBridge = require("./tempDirBridge.cjs");
|
||||
const managed = tempDirBridge.getTempDir();
|
||||
assert.equal(getTempBase(), managed);
|
||||
assert.match(managed, /Netcatty/i);
|
||||
});
|
||||
|
||||
test("classifyAskpassPrompt re-export works", () => {
|
||||
assert.equal(classifyAskpassPrompt("Enter PIN for authenticator"), "pin");
|
||||
assert.equal(classifyAskpassPrompt("Confirm user presence"), "touch");
|
||||
});
|
||||
|
||||
test("releaseFidoAskpassLease cancels outstanding FIDO prompts for that lease", async () => {
|
||||
const sent = [];
|
||||
const sender = {
|
||||
id: 77,
|
||||
isDestroyed: () => false,
|
||||
send: (channel, payload) => sent.push({ channel, payload }),
|
||||
};
|
||||
const leaseId = "lease-release-cancel-test";
|
||||
const pending = fidoPromptHandler.requestFidoPrompt(sender, {
|
||||
kind: "pin",
|
||||
message: "Enter PIN",
|
||||
leaseId,
|
||||
});
|
||||
releaseFidoAskpassLease(leaseId);
|
||||
assert.deepEqual(await pending, { cancelled: true });
|
||||
assert.equal(fidoPromptHandler.getRequests().size, 0);
|
||||
assert.ok(sent.some((entry) => entry.channel === "netcatty:fido-prompt-cancelled"));
|
||||
});
|
||||
|
||||
test("Windows notification helpers exit immediately while hints follow PIN and lease ownership", async () => {
|
||||
class Socket extends EventEmitter {
|
||||
setEncoding() {}
|
||||
end(data) { this.reply = JSON.parse(data); this.emit("close"); }
|
||||
}
|
||||
const sender = { id: 2823, isDestroyed: () => false, send() {} };
|
||||
const env = buildFidoAskpassEnv({ resolveWebContents: () => sender });
|
||||
const leaseId = env.NETCATTY_FIDO_ASKPASS_LEASE;
|
||||
const { handleAskpassClient } = require("./fidoAskpass.cjs");
|
||||
try {
|
||||
const notification = new Socket();
|
||||
handleAskpassClient(notification, "win32");
|
||||
notification.emit("data", `${JSON.stringify({ mode: "none", leaseId, prompt: "Touch security key" })}\n`);
|
||||
assert.equal(notification.reply.ok, true, "native helper must not block device-failure replies");
|
||||
assert.equal(fidoPromptHandler.getRequests().size, 1, "helper exit must not dismiss the independent hint");
|
||||
const pin = new Socket();
|
||||
handleAskpassClient(pin, "win32");
|
||||
pin.emit("data", `${JSON.stringify({ leaseId, prompt: "Enter PIN for authenticator:" })}\n`);
|
||||
const requests = [...fidoPromptHandler.getRequests()];
|
||||
assert.equal(requests.length, 1, "a stale touch hint must not block PIN input");
|
||||
assert.equal(requests[0][1].kind, "pin");
|
||||
fidoPromptHandler.handleResponse({ sender }, { requestId: requests[0][0], response: "fixture-pin" });
|
||||
await new Promise(setImmediate);
|
||||
assert.equal(pin.reply.response, "fixture-pin");
|
||||
const hints = [...fidoPromptHandler.getRequests().values()];
|
||||
assert.equal(hints.length, 1);
|
||||
assert.equal(hints[0].kind, "touch");
|
||||
releaseFidoAskpassLease(leaseId);
|
||||
assert.equal(fidoPromptHandler.getRequests().size, 0);
|
||||
} finally { releaseFidoAskpassLease(leaseId); shutdownFidoAskpass(); }
|
||||
});
|
||||
@@ -0,0 +1,36 @@
|
||||
"use strict";
|
||||
|
||||
const test = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const path = require("node:path");
|
||||
const {
|
||||
resolveFidoAskpassSocketPath,
|
||||
isWindowsNamedPipePath,
|
||||
selectFidoAskpassWrapper,
|
||||
} = require("./fidoAskpass.cjs");
|
||||
|
||||
test("resolveFidoAskpassSocketPath uses Windows named-pipe namespace on win32", () => {
|
||||
const sock = resolveFidoAskpassSocketPath("C:\\Users\\netcatty\\Temp\\askpass-base", "win32");
|
||||
assert.equal(isWindowsNamedPipePath(sock), true);
|
||||
assert.match(sock, /^\\\\\.\\pipe\\netcatty-fido-askpass-/);
|
||||
assert.equal(path.isAbsolute(sock), process.platform === "win32");
|
||||
});
|
||||
|
||||
test("Windows MSYS helpers use a shell wrapper while native OpenSSH keeps its command wrapper", () => {
|
||||
const wrapper = "C:\\Users\\First Last\\askpass.cmd";
|
||||
const msys = "C:\\msys64\\usr\\bin\\ssh-keygen.exe";
|
||||
const native = "C:\\Windows\\System32\\OpenSSH\\ssh-keygen.exe";
|
||||
const files = new Set([msys, native, "C:\\msys64\\usr\\bin\\msys-2.0.dll"]);
|
||||
const exists = file => files.has(file);
|
||||
assert.equal(selectFidoAskpassWrapper(wrapper, msys, "win32", {}, exists), "C:/Users/First Last/askpass.sh");
|
||||
assert.equal(selectFidoAskpassWrapper(wrapper, native, "win32", {}, exists), wrapper);
|
||||
assert.equal(selectFidoAskpassWrapper(wrapper, "ssh-keygen", "win32", {
|
||||
PATH: "C:\\Windows\\System32\\OpenSSH;C:\\msys64\\usr\\bin",
|
||||
}, exists), wrapper, "the first executable on PATH determines the runtime");
|
||||
});
|
||||
|
||||
test("resolveFidoAskpassSocketPath uses filesystem sockets on unix", () => {
|
||||
const sock = resolveFidoAskpassSocketPath("/tmp/netcatty-fido-askpass-abcd", "linux");
|
||||
assert.equal(sock, "/tmp/netcatty-fido-askpass-abcd/askpass.sock");
|
||||
assert.equal(isWindowsNamedPipePath(sock), false);
|
||||
});
|
||||
@@ -0,0 +1,115 @@
|
||||
"use strict";
|
||||
|
||||
// Native OpenSSH clients must sign through the same caller-bound agent wrapper
|
||||
// as ssh2. A raw singleton socket cannot attribute a request to its window.
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
const net = require("node:net");
|
||||
const { randomUUID, randomBytes, timingSafeEqual } = require("node:crypto");
|
||||
const { execFile } = require("node:child_process");
|
||||
const { promisify } = require("node:util");
|
||||
const { isWindowsPosixSsh } = require("./fidoAskpass.cjs");
|
||||
const { AgentProtocol } = require("ssh2/lib/agent.js");
|
||||
|
||||
// MSYS/Cygwin emulate AF_UNIX with an authenticated loopback connection.
|
||||
// Match their secret + peer-credential handshake; never expose agent protocol
|
||||
// until the caller proves it can read the private socket descriptor file.
|
||||
function authenticateCygwinSocket(socket, secret, onAuthenticated) {
|
||||
let buffered = Buffer.alloc(0);
|
||||
let haveSecret = false;
|
||||
socket.setTimeout(5000, () => socket.destroy());
|
||||
function receive(chunk) {
|
||||
buffered = Buffer.concat([buffered, chunk]);
|
||||
if (!haveSecret) {
|
||||
if (buffered.length < 16) return;
|
||||
if (!timingSafeEqual(buffered.subarray(0, 16), secret)) { socket.destroy(); return; }
|
||||
buffered = buffered.subarray(16);
|
||||
haveSecret = true;
|
||||
socket.write(secret);
|
||||
}
|
||||
if (buffered.length < 12) return;
|
||||
const credentials = Buffer.from(buffered.subarray(0, 12));
|
||||
credentials.writeUInt32LE(process.pid, 0);
|
||||
socket.write(credentials);
|
||||
socket.removeListener("data", receive);
|
||||
socket.setTimeout(0);
|
||||
if (buffered.length > 12) socket.unshift(buffered.subarray(12));
|
||||
onAuthenticated(socket);
|
||||
}
|
||||
socket.on("data", receive);
|
||||
}
|
||||
|
||||
async function createNativeFidoAgent(agent, options = {}) {
|
||||
const base = require("./tempDirBridge.cjs").getTempDir();
|
||||
const dir = await fs.promises.mkdtemp(path.join(base, "fn-"));
|
||||
const cygwin = isWindowsPosixSsh(options.sshBinaryPath);
|
||||
const secret = cygwin ? randomBytes(16) : null;
|
||||
const socketPath = process.platform === "win32" && !cygwin
|
||||
? `\\\\.\\pipe\\netcatty-fido-native-${randomUUID()}`
|
||||
: path.join(dir, "agent.sock");
|
||||
const sockets = new Set();
|
||||
let closed = false;
|
||||
function serve(socket) {
|
||||
const protocol = new AgentProtocol(false);
|
||||
protocol.on("error", () => socket.destroy());
|
||||
const respond = operation => {
|
||||
if (!closed && !socket.destroyed && !protocol.destroyed) {
|
||||
try { operation(); } catch { socket.destroy(); }
|
||||
}
|
||||
};
|
||||
protocol.on("identities", request => {
|
||||
agent.getIdentities((error, keys) => respond(() => error
|
||||
? protocol.failureReply(request) : protocol.getIdentitiesReply(request, keys)));
|
||||
});
|
||||
protocol.on("sign", (request, key, data, flags) => {
|
||||
// Enforce the selected identities for direct native signing requests too.
|
||||
agent.getIdentities((error, keys) => {
|
||||
if (closed || socket.destroyed) return;
|
||||
if (error || !Array.isArray(keys)
|
||||
|| !keys.some(candidate => candidate.getPublicSSH().equals(key.getPublicSSH()))) {
|
||||
respond(() => protocol.failureReply(request));
|
||||
return;
|
||||
}
|
||||
agent.sign(key, data, flags, (signError, signature) => respond(() => signError
|
||||
? protocol.failureReply(request) : protocol.signReply(request, signature)));
|
||||
});
|
||||
});
|
||||
socket.pipe(protocol).pipe(socket);
|
||||
}
|
||||
const server = net.createServer(socket => {
|
||||
sockets.add(socket);
|
||||
socket.on("error", () => {});
|
||||
socket.once("close", () => sockets.delete(socket));
|
||||
if (cygwin) authenticateCygwinSocket(socket, secret, serve);
|
||||
else serve(socket);
|
||||
});
|
||||
function close() {
|
||||
if (closed) return;
|
||||
closed = true;
|
||||
for (const socket of sockets) socket.destroy();
|
||||
server.close(() => fs.promises.rm(dir, { recursive: true, force: true }).catch(() => {}));
|
||||
}
|
||||
try {
|
||||
await new Promise((resolve, reject) => {
|
||||
server.once("error", reject);
|
||||
const address = cygwin ? { host: "127.0.0.1", port: 0 } : socketPath;
|
||||
server.listen(address, () => { server.removeListener("error", reject); resolve(); });
|
||||
});
|
||||
if (cygwin) {
|
||||
const words = Array.from({ length: 4 }, (_, i) => secret.readUInt32LE(i * 4).toString(16).toUpperCase().padStart(8, "0"));
|
||||
await fs.promises.writeFile(socketPath, `!<socket >${server.address().port} s ${words.join("-")}\0`, { mode: 0o600, flag: "wx" });
|
||||
// The SYSTEM attribute makes MSYS recognize the descriptor as a socket.
|
||||
await promisify(execFile)(path.win32.join(process.env.SystemRoot || "C:\\Windows", "System32", "attrib.exe"),
|
||||
["+S", socketPath], { windowsHide: true, timeout: 5000 });
|
||||
}
|
||||
if (process.platform !== "win32") await fs.promises.chmod(socketPath, 0o600);
|
||||
server.on("error", close);
|
||||
return { socketPath: cygwin ? socketPath.replace(/\\/g, "/") : socketPath, close };
|
||||
} catch (error) {
|
||||
close();
|
||||
await fs.promises.rm(dir, { recursive: true, force: true }).catch(() => {});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { createNativeFidoAgent, authenticateCygwinSocket };
|
||||
@@ -0,0 +1,179 @@
|
||||
"use strict";
|
||||
|
||||
const test = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const { generateKeyPairSync, randomBytes } = require("node:crypto");
|
||||
const { execFile } = require("node:child_process");
|
||||
const { promisify } = require("node:util");
|
||||
const net = require("node:net");
|
||||
const { once } = require("node:events");
|
||||
const { utils } = require("ssh2");
|
||||
const { createAgent } = require("ssh2/lib/agent.js");
|
||||
const { createNativeFidoAgent, authenticateCygwinSocket } = require("./fidoNativeAgent.cjs");
|
||||
const askpass = require("./fidoAskpass.cjs");
|
||||
const prompts = require("./fidoPromptHandler.cjs");
|
||||
const run = promisify(execFile);
|
||||
|
||||
function key() {
|
||||
const pair = generateKeyPairSync("rsa", { modulusLength: 2048,
|
||||
privateKeyEncoding: { type: "pkcs1", format: "pem" },
|
||||
publicKeyEncoding: { type: "spki", format: "pem" } });
|
||||
return utils.parseKey(pair.privateKey);
|
||||
}
|
||||
|
||||
function sign(agent, key, data) {
|
||||
return new Promise((resolve, reject) => agent.sign(key, data, { hash: "sha256" },
|
||||
(error, signature) => error ? reject(error) : resolve(signature)));
|
||||
}
|
||||
|
||||
test("native agent sockets attribute concurrent helper subprocess prompts to their signing windows", async () => {
|
||||
const selected = key();
|
||||
const seen = [];
|
||||
const proxies = [];
|
||||
const envs = [];
|
||||
try {
|
||||
for (const id of [2823, 2824]) {
|
||||
const sender = { id, isDestroyed: () => false, send(channel, request) {
|
||||
if (channel !== "netcatty:fido-prompt-request") return;
|
||||
seen.push(id);
|
||||
queueMicrotask(() => prompts.handleResponse({ sender }, {
|
||||
requestId: request.requestId, response: `test-pin-${id}`, cancelled: false,
|
||||
}));
|
||||
} };
|
||||
const env = askpass.buildFidoAskpassEnv({ resolveWebContents: () => sender });
|
||||
envs.push(env);
|
||||
const wrapped = {
|
||||
getIdentities: callback => callback(null, [selected]),
|
||||
sign(publicKey, data, flags, callback) {
|
||||
askpass.runWithFidoSigningLease(env.NETCATTY_FIDO_ASKPASS_LEASE, finish => {
|
||||
const { NETCATTY_FIDO_ASKPASS_LEASE: _lease, ...helperEnv } = env;
|
||||
// Real generated helper subprocess, with the shared agent's
|
||||
// leaseless environment, as OpenSSH's ssh-sk-helper would use it.
|
||||
run(process.execPath, [askpass.ensureFidoAskpass().scriptPath, "Enter PIN for authenticator:"], {
|
||||
env: { ...process.env, ...helperEnv }, timeout: 10000,
|
||||
}).then(result => {
|
||||
assert.equal(result.stdout.trim(), `test-pin-${id}`);
|
||||
finish();
|
||||
callback(null, selected.sign(data, flags.hash));
|
||||
}, error => { finish(); callback(error); });
|
||||
});
|
||||
},
|
||||
};
|
||||
proxies.push(await createNativeFidoAgent(wrapped));
|
||||
}
|
||||
const data = Buffer.from("native SSH signing request");
|
||||
const signatures = await Promise.all(proxies.map(proxy => sign(createAgent(proxy.socketPath), selected, data)));
|
||||
assert.deepEqual(seen, process.platform === "win32" ? [2823, 2823, 2824, 2824] : [2823, 2824]);
|
||||
for (const signature of signatures) assert.equal(selected.verify(data, signature, "sha256"), true);
|
||||
} finally {
|
||||
for (const proxy of proxies) proxy.close();
|
||||
for (const env of envs) askpass.releaseFidoAskpassLease(env.NETCATTY_FIDO_ASKPASS_LEASE);
|
||||
askpass.shutdownFidoAskpass();
|
||||
}
|
||||
});
|
||||
|
||||
test("native agent rejects signing identities outside the session selection and closes on release", async () => {
|
||||
const selected = key();
|
||||
const unrelated = key();
|
||||
let signCalls = 0;
|
||||
const proxy = await createNativeFidoAgent({
|
||||
getIdentities: callback => callback(null, [selected]),
|
||||
sign: () => { signCalls += 1; },
|
||||
});
|
||||
try {
|
||||
const client = createAgent(proxy.socketPath);
|
||||
await assert.rejects(sign(client, unrelated, Buffer.from("unauthorized")), /failure/);
|
||||
assert.equal(signCalls, 0);
|
||||
} finally { proxy.close(); }
|
||||
await assert.rejects(new Promise((resolve, reject) => createAgent(proxy.socketPath).getIdentities(
|
||||
error => error ? reject(error) : resolve())), /Failed to connect to agent/);
|
||||
});
|
||||
|
||||
test("unsupported OpenSSH extensions do not corrupt the following identity request", { timeout: 5000 }, async () => {
|
||||
const selected = key();
|
||||
const proxy = await createNativeFidoAgent({ getIdentities: callback => callback(null, [selected]) });
|
||||
const socket = net.createConnection(proxy.socketPath);
|
||||
function frame(type, payload = Buffer.alloc(0)) {
|
||||
const buffer = Buffer.alloc(5 + payload.length);
|
||||
buffer.writeUInt32BE(1 + payload.length);
|
||||
buffer[4] = type;
|
||||
payload.copy(buffer, 5);
|
||||
return buffer;
|
||||
}
|
||||
try {
|
||||
await once(socket, "connect");
|
||||
const responses = new Promise((resolve, reject) => {
|
||||
let buffered = Buffer.alloc(0);
|
||||
const types = [];
|
||||
socket.on("error", reject);
|
||||
socket.on("data", chunk => {
|
||||
buffered = Buffer.concat([buffered, chunk]);
|
||||
while (buffered.length >= 5 && buffered.length >= 4 + buffered.readUInt32BE()) {
|
||||
types.push(buffered[4]);
|
||||
buffered = buffered.subarray(4 + buffered.readUInt32BE());
|
||||
}
|
||||
if (types.length >= 3) resolve(types);
|
||||
});
|
||||
});
|
||||
// Coalesce identities, an unknown payload, and identities in one write.
|
||||
// This also proves parsing advances correctly after a preceding frame.
|
||||
socket.write(Buffer.concat([frame(11), frame(27, Buffer.from("unsupported-extension-payload")), frame(11)]));
|
||||
assert.deepEqual(await responses, [12, 5, 12]);
|
||||
} finally { socket.destroy(); proxy.close(); }
|
||||
});
|
||||
|
||||
|
||||
test("Cygwin socket authenticates fragmented handshakes and rejects a wrong secret", { timeout: 5000 }, async () => {
|
||||
const secret = randomBytes(16);
|
||||
let admitted = 0;
|
||||
const sockets = new Set();
|
||||
const server = net.createServer(socket => {
|
||||
sockets.add(socket);
|
||||
socket.on("error", () => {});
|
||||
socket.once("close", () => sockets.delete(socket));
|
||||
authenticateCygwinSocket(socket, secret, authenticated => {
|
||||
admitted += 1;
|
||||
authenticated.on("data", data => authenticated.write(data));
|
||||
});
|
||||
});
|
||||
server.listen(0, "127.0.0.1");
|
||||
await once(server, "listening");
|
||||
const connect = () => net.createConnection(server.address().port, "127.0.0.1");
|
||||
const bad = connect();
|
||||
const good = connect();
|
||||
const goodConnected = once(good, "connect");
|
||||
try {
|
||||
await once(bad, "connect");
|
||||
const closed = once(bad, "close");
|
||||
bad.write(Buffer.alloc(16));
|
||||
await closed;
|
||||
assert.equal(admitted, 0);
|
||||
await goodConnected;
|
||||
const echo = once(good, "data");
|
||||
good.write(secret.subarray(0, 7));
|
||||
good.write(secret.subarray(7));
|
||||
assert.deepEqual((await echo)[0], secret);
|
||||
const credentials = Buffer.alloc(12);
|
||||
credentials.writeUInt32LE(1001, 4);
|
||||
credentials.writeUInt32LE(1002, 8);
|
||||
const reply = new Promise(resolve => {
|
||||
let bytes = Buffer.alloc(0);
|
||||
good.on("data", chunk => {
|
||||
bytes = Buffer.concat([bytes, chunk]);
|
||||
if (bytes.length >= 17) resolve(bytes);
|
||||
});
|
||||
});
|
||||
good.write(credentials.subarray(0, 5));
|
||||
// Preserve agent bytes coalesced with the end of the credential handshake.
|
||||
good.write(Buffer.concat([credentials.subarray(5), Buffer.from("agent")]));
|
||||
const bytes = await reply;
|
||||
assert.equal(bytes.readUInt32LE(0), process.pid);
|
||||
assert.deepEqual(bytes.subarray(4, 12), credentials.subarray(4));
|
||||
assert.equal(bytes.subarray(12).toString(), "agent");
|
||||
assert.equal(admitted, 1);
|
||||
} finally {
|
||||
bad.destroy(); good.destroy();
|
||||
for (const socket of sockets) socket.destroy();
|
||||
await new Promise(resolve => server.close(resolve));
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,209 @@
|
||||
"use strict";
|
||||
|
||||
/**
|
||||
* FIDO2 PIN / touch prompts for OpenSSH sk-* flows.
|
||||
* Mirrors passphraseHandler: main requests → renderer modal → respond IPC.
|
||||
*/
|
||||
|
||||
const { randomUUID } = require("node:crypto");
|
||||
|
||||
/** @type {Map<string, {
|
||||
* resolveCallback: (result: { cancelled?: boolean, response?: string|null }) => void,
|
||||
* sender: Electron.WebContents,
|
||||
* webContentsId: number,
|
||||
* kind: string,
|
||||
* leaseId: string,
|
||||
* createdAt: number,
|
||||
* timeoutId: NodeJS.Timeout,
|
||||
* }>} */
|
||||
const fidoPromptRequests = new Map();
|
||||
|
||||
const REQUEST_TTL_MS = 3 * 60 * 1000;
|
||||
|
||||
function generateRequestId(prefix = "fido") {
|
||||
return `${prefix}-${randomUUID()}`;
|
||||
}
|
||||
|
||||
function settleRequest(requestId, result, notification) {
|
||||
const pending = fidoPromptRequests.get(requestId);
|
||||
if (!pending) return false;
|
||||
|
||||
if (pending.timeoutId) clearTimeout(pending.timeoutId);
|
||||
pending.abortCleanup?.();
|
||||
fidoPromptRequests.delete(requestId);
|
||||
|
||||
if (notification) {
|
||||
try {
|
||||
if (!pending.sender?.isDestroyed?.()) {
|
||||
pending.sender?.send?.(notification.channel, {
|
||||
requestId,
|
||||
...(notification.payload || {}),
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn(`[FidoPrompt] Failed to send ${notification.channel}:`, err.message);
|
||||
}
|
||||
}
|
||||
|
||||
pending.resolveCallback(result);
|
||||
return true;
|
||||
}
|
||||
|
||||
function cancelFidoPromptRequest(requestId, reason = "cancelled") {
|
||||
return settleRequest(
|
||||
requestId,
|
||||
{ cancelled: true },
|
||||
{
|
||||
channel: "netcatty:fido-prompt-cancelled",
|
||||
payload: { reason },
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancel prompts owned by an askpass lease (ssh-add or inherited agent signing).
|
||||
* @param {string} leaseId
|
||||
* @param {string} [reason]
|
||||
* @returns {number}
|
||||
*/
|
||||
function cancelFidoPromptRequestsForLease(leaseId, reason = "lease-released", notificationsOnly = false) {
|
||||
if (typeof leaseId !== "string" || !leaseId) return 0;
|
||||
let cancelled = 0;
|
||||
for (const [requestId, pending] of [...fidoPromptRequests.entries()]) {
|
||||
if (pending.leaseId !== leaseId) continue;
|
||||
if (notificationsOnly && !pending.notificationOnly) continue;
|
||||
if (cancelFidoPromptRequest(requestId, reason)) cancelled += 1;
|
||||
}
|
||||
return cancelled;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Electron.WebContents|null|undefined} sender
|
||||
* @param {{
|
||||
* kind: "pin" | "touch" | "confirm" | "recovery",
|
||||
* reason?: string,
|
||||
* allowPasswordFallback?: boolean,
|
||||
* message?: string,
|
||||
* title?: string,
|
||||
* keyName?: string,
|
||||
* notificationOnly?: boolean,
|
||||
* leaseId?: string,
|
||||
* signal?: AbortSignal,
|
||||
* }} options
|
||||
* @returns {Promise<{ cancelled?: boolean, response?: string|null } | null>}
|
||||
*/
|
||||
function requestFidoPrompt(sender, options = {}) {
|
||||
return new Promise((resolve) => {
|
||||
if (!sender || sender.isDestroyed?.()) {
|
||||
console.warn("[FidoPrompt] No live sender; cannot show FIDO prompt");
|
||||
resolve(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const kind = ["touch", "confirm", "recovery"].includes(options.kind) ? options.kind : "pin";
|
||||
const leaseId = typeof options.leaseId === "string" ? options.leaseId : "";
|
||||
const requestId = generateRequestId(kind);
|
||||
const timeoutId = setTimeout(() => {
|
||||
settleRequest(
|
||||
requestId,
|
||||
{ cancelled: true },
|
||||
{ channel: "netcatty:fido-prompt-timeout" },
|
||||
);
|
||||
}, REQUEST_TTL_MS);
|
||||
|
||||
fidoPromptRequests.set(requestId, {
|
||||
resolveCallback: resolve,
|
||||
sender,
|
||||
webContentsId: sender.id,
|
||||
kind,
|
||||
leaseId,
|
||||
notificationOnly: options.notificationOnly === true,
|
||||
createdAt: Date.now(),
|
||||
timeoutId,
|
||||
});
|
||||
|
||||
if (options.signal) {
|
||||
const onAbort = () => cancelFidoPromptRequest(requestId, "helper-exited");
|
||||
const pending = fidoPromptRequests.get(requestId);
|
||||
pending.abortCleanup = () => options.signal.removeEventListener("abort", onAbort);
|
||||
options.signal.addEventListener("abort", onAbort, { once: true });
|
||||
if (options.signal.aborted) {
|
||||
onAbort();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
sender.send("netcatty:fido-prompt-request", {
|
||||
requestId,
|
||||
kind,
|
||||
message: options.message || "",
|
||||
title: options.title || "",
|
||||
keyName: options.keyName || "",
|
||||
notificationOnly: options.notificationOnly === true,
|
||||
reason: options.reason,
|
||||
allowPasswordFallback: options.allowPasswordFallback === true,
|
||||
});
|
||||
} catch (err) {
|
||||
console.error("[FidoPrompt] Failed to send request:", err);
|
||||
settleRequest(requestId, null);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function handleResponse(_event, payload = {}) {
|
||||
const { requestId, response, cancelled } = payload;
|
||||
const pending = fidoPromptRequests.get(requestId);
|
||||
if (!pending) {
|
||||
return { success: false, error: "Request not found" };
|
||||
}
|
||||
if (_event?.sender?.id !== pending.webContentsId) {
|
||||
return { success: false, error: "Wrong sender" };
|
||||
}
|
||||
if (cancelled) {
|
||||
settleRequest(requestId, { cancelled: true });
|
||||
} else {
|
||||
settleRequest(requestId, { response: typeof response === "string" ? response : "" });
|
||||
}
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
function registerHandler(ipcMain) {
|
||||
ipcMain.handle("netcatty:fido-prompt:respond", handleResponse);
|
||||
}
|
||||
|
||||
function getRequests() {
|
||||
return fidoPromptRequests;
|
||||
}
|
||||
|
||||
/**
|
||||
* Classify OpenSSH / ssh-sk-helper askpass prompt text.
|
||||
* @param {string} prompt
|
||||
* @returns {"pin"|"touch"|"confirm"}
|
||||
*/
|
||||
function classifyAskpassPrompt(prompt) {
|
||||
const p = String(prompt || "").toLowerCase();
|
||||
// ssh-agent combines both requirements in one prompt for verify-required
|
||||
// keys: "Enter PIN and confirm user presence ...". It still expects a PIN
|
||||
// on stdout; classifying it as touch would silently return an empty string.
|
||||
if (/\bpin\b|密码|密碼|口令/.test(p)) return "pin";
|
||||
if (/confirm user presence|touch (your |the )?(security )?key|tap (your |the )?(security )?key|user presence|请触摸|請觸摸|请轻触/.test(p)) {
|
||||
return "touch";
|
||||
}
|
||||
if (/pin|authenticator|fido|security key|enter.*code|密码|pin 码|口令/.test(p)) {
|
||||
return "pin";
|
||||
}
|
||||
// Default to PIN-style secret entry for unknown askpass prompts from sk-helper.
|
||||
return "pin";
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
generateRequestId,
|
||||
requestFidoPrompt,
|
||||
cancelFidoPromptRequest,
|
||||
cancelFidoPromptRequestsForLease,
|
||||
handleResponse,
|
||||
registerHandler,
|
||||
getRequests,
|
||||
classifyAskpassPrompt,
|
||||
};
|
||||
@@ -0,0 +1,106 @@
|
||||
"use strict";
|
||||
|
||||
const test = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const {
|
||||
classifyAskpassPrompt,
|
||||
requestFidoPrompt,
|
||||
cancelFidoPromptRequestsForLease,
|
||||
handleResponse,
|
||||
getRequests,
|
||||
} = require("./fidoPromptHandler.cjs");
|
||||
|
||||
test("classifyAskpassPrompt maps OpenSSH sk-helper prompts", () => {
|
||||
assert.equal(classifyAskpassPrompt("Enter PIN for authenticator:"), "pin");
|
||||
assert.equal(classifyAskpassPrompt("Confirm user presence for key ED25519-SK"), "touch");
|
||||
assert.equal(classifyAskpassPrompt("Touch your security key"), "touch");
|
||||
assert.equal(classifyAskpassPrompt("请触摸你的安全密钥"), "touch");
|
||||
assert.equal(classifyAskpassPrompt("Something unknown"), "pin");
|
||||
});
|
||||
|
||||
test("requestFidoPrompt delivers IPC payload and resolves on respond", async () => {
|
||||
const sent = [];
|
||||
const sender = {
|
||||
id: 42,
|
||||
isDestroyed: () => false,
|
||||
send: (channel, payload) => sent.push({ channel, payload }),
|
||||
};
|
||||
|
||||
const pending = requestFidoPrompt(sender, {
|
||||
kind: "pin",
|
||||
message: "Enter PIN for authenticator:",
|
||||
keyName: "yubi",
|
||||
});
|
||||
|
||||
assert.equal(sent.length, 1);
|
||||
assert.equal(sent[0].channel, "netcatty:fido-prompt-request");
|
||||
assert.equal(sent[0].payload.kind, "pin");
|
||||
assert.equal(getRequests().size, 1);
|
||||
|
||||
const requestId = sent[0].payload.requestId;
|
||||
const result = handleResponse(
|
||||
{ sender: { id: 42 } },
|
||||
{ requestId, response: "123456", cancelled: false },
|
||||
);
|
||||
assert.equal(result.success, true);
|
||||
|
||||
const resolved = await pending;
|
||||
assert.deepEqual(resolved, { response: "123456" });
|
||||
assert.equal(getRequests().size, 0);
|
||||
});
|
||||
|
||||
test("requestFidoPrompt cancel path", async () => {
|
||||
const sent = [];
|
||||
const sender = {
|
||||
id: 7,
|
||||
isDestroyed: () => false,
|
||||
send: (channel, payload) => sent.push({ channel, payload }),
|
||||
};
|
||||
const pending = requestFidoPrompt(sender, { kind: "touch", message: "Touch key" });
|
||||
const requestId = sent[0].payload.requestId;
|
||||
handleResponse({ sender: { id: 7 } }, { requestId, cancelled: true });
|
||||
const resolved = await pending;
|
||||
assert.deepEqual(resolved, { cancelled: true });
|
||||
});
|
||||
|
||||
test("cancelFidoPromptRequestsForLease settles owned prompts and notifies renderer", async () => {
|
||||
const sent = [];
|
||||
const sender = {
|
||||
id: 11,
|
||||
isDestroyed: () => false,
|
||||
send: (channel, payload) => sent.push({ channel, payload }),
|
||||
};
|
||||
const pending = requestFidoPrompt(sender, {
|
||||
kind: "pin",
|
||||
message: "Enter PIN",
|
||||
leaseId: "lease-abc",
|
||||
});
|
||||
const other = requestFidoPrompt(sender, {
|
||||
kind: "touch",
|
||||
message: "Touch key",
|
||||
leaseId: "lease-other",
|
||||
});
|
||||
const otherRequestId = sent.find((entry) => (
|
||||
entry.channel === "netcatty:fido-prompt-request" && entry.payload.kind === "touch"
|
||||
)).payload.requestId;
|
||||
|
||||
assert.equal(cancelFidoPromptRequestsForLease("lease-abc", "lease-released"), 1);
|
||||
assert.deepEqual(await pending, { cancelled: true });
|
||||
assert.equal(getRequests().size, 1);
|
||||
assert.ok(sent.some((entry) => (
|
||||
entry.channel === "netcatty:fido-prompt-cancelled"
|
||||
&& entry.payload.reason === "lease-released"
|
||||
)));
|
||||
|
||||
handleResponse({ sender: { id: 11 } }, { requestId: otherRequestId, cancelled: true });
|
||||
await other;
|
||||
});
|
||||
|
||||
test("combined OpenSSH PIN and user-presence prompts require secret entry", () => {
|
||||
for (const algorithm of ["ED25519-SK", "ECDSA-SK"]) {
|
||||
assert.equal(
|
||||
classifyAskpassPrompt(`Enter PIN and confirm user presence for ${algorithm} key SHA256:test: `),
|
||||
"pin",
|
||||
);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,49 @@
|
||||
"use strict";
|
||||
|
||||
const prompts = require("./fidoPromptHandler.cjs");
|
||||
|
||||
function failureReason(context) {
|
||||
if (context.timedOut) return "timeout";
|
||||
if (context.helperError === 60) return "device-unavailable";
|
||||
if (context.helperError === 43 && context.pinSubmitted) return "pin-failed";
|
||||
return "failed";
|
||||
}
|
||||
|
||||
function cancelledError() {
|
||||
const error = new Error("Security key authentication cancelled.");
|
||||
error.code = "ERR_FIDO_CANCELLED";
|
||||
return error;
|
||||
}
|
||||
|
||||
async function signWithRecovery(context, options, sign) {
|
||||
context.keyName = options.keyLabel || "";
|
||||
context.allowPasswordFallback = Boolean(options.password);
|
||||
for (;;) {
|
||||
if (context.controller.signal.aborted) return { error: cancelledError() };
|
||||
context.helperError = null;
|
||||
context.pinSubmitted = false;
|
||||
context.cancelled = false;
|
||||
context.timedOut = false;
|
||||
context.attemptController = new AbortController();
|
||||
const result = await new Promise(resolve => {
|
||||
try { sign((error, signature) => resolve({ error, signature })); }
|
||||
catch (error) { resolve({ error }); }
|
||||
});
|
||||
context.attemptController.abort();
|
||||
if (context.cancelled || context.controller.signal.aborted) return { error: cancelledError() };
|
||||
if (!result.error && !context.timedOut) return result;
|
||||
if (context.timedOut) result.error = new Error("Security key operation timed out");
|
||||
const sender = options.resolveWebContents?.();
|
||||
if (!sender || sender.isDestroyed?.()) return { error: cancelledError() };
|
||||
const response = await prompts.requestFidoPrompt(sender, {
|
||||
kind: "recovery", reason: failureReason(context), keyName: context.keyName,
|
||||
allowPasswordFallback: context.allowPasswordFallback, leaseId: context.leaseId,
|
||||
signal: context.controller.signal,
|
||||
});
|
||||
if (response?.response === "retry") continue;
|
||||
if (response?.response === "fallback" && context.allowPasswordFallback) return result;
|
||||
return { error: cancelledError() };
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { signWithRecovery, failureReason, cancelledError };
|
||||
@@ -0,0 +1,41 @@
|
||||
"use strict";
|
||||
|
||||
const test = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const { signWithRecovery } = require("./fidoSignFlow.cjs");
|
||||
const prompts = require("./fidoPromptHandler.cjs");
|
||||
|
||||
test("a signature arriving after cancellation is never returned to SSH", async () => {
|
||||
const context = { leaseId: "cancelled-sign", controller: new AbortController() };
|
||||
const result = await signWithRecovery(context, {}, callback => {
|
||||
context.controller.abort();
|
||||
callback(null, Buffer.from("late signature"));
|
||||
});
|
||||
assert.equal(result.error.code, "ERR_FIDO_CANCELLED");
|
||||
assert.equal(result.signature, undefined);
|
||||
});
|
||||
|
||||
test("a device result after the deadline requires recovery even if its signature is valid", async () => {
|
||||
const context = { leaseId: "late-sign", controller: new AbortController() };
|
||||
const received = [];
|
||||
const sender = { id: 311, isDestroyed: () => false, send(channel, request) {
|
||||
if (channel !== "netcatty:fido-prompt-request") return;
|
||||
received.push(request);
|
||||
queueMicrotask(() => prompts.handleResponse({ sender }, { requestId: request.requestId, cancelled: true }));
|
||||
} };
|
||||
const result = await signWithRecovery(context, { resolveWebContents: () => sender }, callback => {
|
||||
context.timedOut = true;
|
||||
callback(null, Buffer.from("late signature"));
|
||||
});
|
||||
assert.equal(received[0].reason, "timeout");
|
||||
assert.equal(result.error.code, "ERR_FIDO_CANCELLED");
|
||||
assert.equal(result.signature, undefined);
|
||||
});
|
||||
|
||||
test("losing the prompt window fails closed instead of choosing a saved password", async () => {
|
||||
const context = { leaseId: "missing-window", controller: new AbortController() };
|
||||
const result = await signWithRecovery(context, { password: "test-only", resolveWebContents: () => null }, callback => {
|
||||
callback(new Error("agent refused"));
|
||||
});
|
||||
assert.equal(result.error.code, "ERR_FIDO_CANCELLED");
|
||||
});
|
||||
@@ -0,0 +1,102 @@
|
||||
"use strict";
|
||||
|
||||
// Observe the OpenSSH helper's error reply before ssh-agent reduces it to a
|
||||
// generic SSH_AGENT_FAILURE. Requests/signatures are forwarded unchanged and
|
||||
// are never logged. Windows agents without an executable wrapper keep their
|
||||
// native helper; callers then use the generic recovery message.
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
|
||||
const HELPER_SCRIPT = String.raw`"use strict";
|
||||
const { spawn } = require("node:child_process");
|
||||
const net = require("node:net");
|
||||
const child = spawn(process.env.NETCATTY_FIDO_REAL_SK_HELPER, process.argv.slice(2), {
|
||||
env: process.env, stdio: ["pipe", "pipe", "inherit"],
|
||||
});
|
||||
let control, response = [], size = 0, finished = false, exiting = false, exitCode = 1, connected = false;
|
||||
const flush = () => {
|
||||
if (exiting || !finished) return;
|
||||
exiting = true;
|
||||
control?.destroy();
|
||||
process.stdout.end(Buffer.concat(response), () => process.exit(exitCode));
|
||||
};
|
||||
child.stdin.on("error", () => {});
|
||||
process.stdin.pipe(child.stdin);
|
||||
child.stdout.on("data", chunk => {
|
||||
size += chunk.length;
|
||||
if (size > 1024 * 1024) { child.kill(); return; }
|
||||
response.push(chunk);
|
||||
});
|
||||
let incoming = "";
|
||||
let errorCode = null;
|
||||
const report = () => {
|
||||
if (!finished) return;
|
||||
if (connected && control?.writable && !control.destroyed) {
|
||||
control.write(JSON.stringify({ phase: "result", errorCode }) + "\n");
|
||||
} else if (!control || control.destroyed) flush();
|
||||
};
|
||||
try {
|
||||
control = net.createConnection(process.env.NETCATTY_FIDO_ASKPASS_SOCK);
|
||||
control.on("connect", () => {
|
||||
connected = true;
|
||||
control.write(JSON.stringify({ type: "sk-helper" }) + "\n");
|
||||
report();
|
||||
});
|
||||
control.setEncoding("utf8");
|
||||
control.on("data", chunk => {
|
||||
incoming += chunk;
|
||||
let nl;
|
||||
while ((nl = incoming.indexOf("\n")) !== -1) {
|
||||
const line = incoming.slice(0, nl); incoming = incoming.slice(nl + 1);
|
||||
let message; try { message = JSON.parse(line); } catch { continue; }
|
||||
if (message.cancel === true) child.kill("SIGTERM");
|
||||
if (message.ack === true) flush();
|
||||
}
|
||||
});
|
||||
control.on("error", flush);
|
||||
control.on("close", flush);
|
||||
} catch { /* The native helper still works if observation is unavailable. */ }
|
||||
const complete = code => {
|
||||
if (finished) return;
|
||||
finished = true; exitCode = code === 0 ? 0 : 1;
|
||||
const packet = Buffer.concat(response);
|
||||
// ssh_msg framing: length, version, reply type, positive SSH error number.
|
||||
errorCode = packet.length === 13 && packet[4] === 5 && packet.readUInt32BE(0) === 9
|
||||
&& packet.readUInt32BE(5) === 0 ? packet.readUInt32BE(9) : null;
|
||||
report();
|
||||
setTimeout(flush, 1000).unref();
|
||||
};
|
||||
child.on("error", () => complete(1));
|
||||
child.on("close", complete);
|
||||
process.on("SIGTERM", () => { child.kill("SIGTERM"); complete(1); });
|
||||
`;
|
||||
|
||||
function resolveSkHelper(sshAgentPath, env = process.env) {
|
||||
const agents = [sshAgentPath];
|
||||
try { agents.push(fs.realpathSync(sshAgentPath)); } catch { /* PATH binary */ }
|
||||
const candidates = [env.SSH_SK_HELPER];
|
||||
for (const agent of agents.filter(Boolean)) {
|
||||
candidates.push(path.resolve(path.dirname(agent), "../libexec/ssh-sk-helper"));
|
||||
candidates.push(path.resolve(path.dirname(agent), "../libexec/ssh/ssh-sk-helper"));
|
||||
}
|
||||
candidates.push("/usr/lib/openssh/ssh-sk-helper", "/usr/libexec/ssh-sk-helper", "/usr/lib/ssh/ssh-sk-helper");
|
||||
return candidates.find(candidate => {
|
||||
if (!candidate) return false;
|
||||
try { fs.accessSync(candidate, fs.constants.X_OK); return true; } catch { return false; }
|
||||
}) || null;
|
||||
}
|
||||
|
||||
function buildSkHelperEnv(sshAgentPath, askpassEnv, env = process.env, platform = process.platform) {
|
||||
if (platform === "win32") return {};
|
||||
const helper = resolveSkHelper(sshAgentPath, env);
|
||||
if (!helper || !askpassEnv?.SSH_ASKPASS) return {};
|
||||
const base = path.dirname(askpassEnv.SSH_ASKPASS);
|
||||
const script = path.join(base, "sk-helper.cjs");
|
||||
const wrapper = path.join(base, "sk-helper.sh");
|
||||
fs.writeFileSync(script, HELPER_SCRIPT, { mode: 0o700 });
|
||||
const quote = value => "'" + value.replace(/'/g, "'\\''") + "'";
|
||||
fs.writeFileSync(wrapper, `#!/bin/sh\nELECTRON_RUN_AS_NODE=1 exec ${quote(process.execPath)} ${quote(script)} "$@"\n`, { mode: 0o700 });
|
||||
return { SSH_SK_HELPER: wrapper, NETCATTY_FIDO_REAL_SK_HELPER: helper };
|
||||
}
|
||||
|
||||
module.exports = { buildSkHelperEnv, resolveSkHelper };
|
||||
@@ -0,0 +1,152 @@
|
||||
"use strict";
|
||||
|
||||
/**
|
||||
* Generate OpenSSH FIDO2 (sk-*) key pairs via ssh-keygen.
|
||||
*
|
||||
* Requires a platform OpenSSH build with libfido2 (e.g. Homebrew openssh on
|
||||
* macOS). The user must touch the security key (and enter a PIN when required).
|
||||
*/
|
||||
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
const { execFile } = require("node:child_process");
|
||||
const { promisify } = require("node:util");
|
||||
const { randomUUID } = require("node:crypto");
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
function resolveSshKeygenBinary(env = process.env) {
|
||||
if (typeof env.NETCATTY_SSH_KEYGEN_PATH === "string" && env.NETCATTY_SSH_KEYGEN_PATH.trim()) {
|
||||
return env.NETCATTY_SSH_KEYGEN_PATH.trim();
|
||||
}
|
||||
if (process.platform === "darwin") {
|
||||
// Prefer Homebrew OpenSSH when present (system ssh often lacks libfido2).
|
||||
for (const candidate of [
|
||||
"/opt/homebrew/bin/ssh-keygen",
|
||||
"/usr/local/bin/ssh-keygen",
|
||||
"/usr/bin/ssh-keygen",
|
||||
]) {
|
||||
try {
|
||||
if (fs.existsSync(candidate)) return candidate;
|
||||
} catch {
|
||||
// continue
|
||||
}
|
||||
}
|
||||
}
|
||||
return "ssh-keygen";
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {{
|
||||
* type: "ED25519-SK" | "ECDSA-SK",
|
||||
* comment?: string,
|
||||
* resident?: boolean,
|
||||
* verifyRequired?: boolean,
|
||||
* env?: NodeJS.ProcessEnv,
|
||||
* execFile?: typeof execFileAsync,
|
||||
* sshKeygenPath?: string,
|
||||
* }} options
|
||||
*/
|
||||
async function generateFidoSshKeyPair(options = {}) {
|
||||
const type = options.type === "ECDSA-SK" ? "ecdsa-sk" : "ed25519-sk";
|
||||
const comment = options.comment || "netcatty-fido-key";
|
||||
const env = options.env || process.env;
|
||||
const run = options.execFile || execFileAsync;
|
||||
const sshKeygen = options.sshKeygenPath || resolveSshKeygenBinary(env);
|
||||
|
||||
let baseDir;
|
||||
try {
|
||||
const tempDirBridge = require("./tempDirBridge.cjs");
|
||||
if (typeof tempDirBridge.getTempDir !== "function") {
|
||||
return {
|
||||
success: false,
|
||||
error: "FIDO2 key generation requires Netcatty temp directory (tempDirBridge unavailable).",
|
||||
};
|
||||
}
|
||||
baseDir = tempDirBridge.getTempDir();
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
return {
|
||||
success: false,
|
||||
error: `FIDO2 key generation requires Netcatty temp directory: ${message}`,
|
||||
};
|
||||
}
|
||||
const tempDir = await fs.promises.mkdtemp(path.join(baseDir, "netcatty-fido-keygen-"));
|
||||
const keyPath = path.join(tempDir, `id_${type.replace(/-sk$/, "_sk")}_${randomUUID().slice(0, 8)}`);
|
||||
|
||||
let askpassLeaseId = null;
|
||||
try {
|
||||
const args = [
|
||||
"-t", type,
|
||||
"-f", keyPath,
|
||||
"-C", comment,
|
||||
"-N", "", // empty passphrase on the key *handle* file (PIN is on the device)
|
||||
];
|
||||
if (options.resident) args.push("-O", "resident");
|
||||
if (options.verifyRequired) args.push("-O", "verify-required");
|
||||
|
||||
let askpassEnv = {};
|
||||
try {
|
||||
const { buildFidoAskpassEnv } = require("./fidoAskpass.cjs");
|
||||
askpassEnv = buildFidoAskpassEnv({
|
||||
resolveWebContents: options.resolveWebContents,
|
||||
sshBinaryPath: sshKeygen,
|
||||
env,
|
||||
});
|
||||
askpassLeaseId = askpassEnv.NETCATTY_FIDO_ASKPASS_LEASE || null;
|
||||
} catch {
|
||||
// GUI askpass unavailable in pure unit tests
|
||||
}
|
||||
|
||||
try {
|
||||
await run(sshKeygen, args, {
|
||||
timeout: 180000,
|
||||
windowsHide: true,
|
||||
env: {
|
||||
...env,
|
||||
...askpassEnv,
|
||||
// Force askpass so PIN/touch go through Netcatty modals (no TTY).
|
||||
SSH_ASKPASS_REQUIRE: askpassEnv.SSH_ASKPASS
|
||||
? "force"
|
||||
: (env.SSH_ASKPASS_REQUIRE || undefined),
|
||||
},
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
} catch (error) {
|
||||
const stderr = error?.stderr?.toString?.() || error?.message || String(error);
|
||||
const hint = /provider|fido|sk_|feature not supported|invalid format|device not found|no such file/i.test(stderr)
|
||||
? " Ensure OpenSSH is built with libfido2 (macOS: brew install openssh libfido2) and a FIDO2 key is plugged in."
|
||||
: "";
|
||||
return {
|
||||
success: false,
|
||||
error: `FIDO2 key generation failed: ${stderr.trim()}.${hint}`,
|
||||
};
|
||||
}
|
||||
|
||||
const [privateKey, publicKey] = await Promise.all([
|
||||
fs.promises.readFile(keyPath, "utf8"),
|
||||
fs.promises.readFile(`${keyPath}.pub`, "utf8"),
|
||||
]);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
privateKey,
|
||||
publicKey: publicKey.trim(),
|
||||
keyType: options.type === "ECDSA-SK" ? "ECDSA-SK" : "ED25519-SK",
|
||||
};
|
||||
} finally {
|
||||
if (askpassLeaseId) {
|
||||
try {
|
||||
require("./fidoAskpass.cjs").releaseFidoAskpassLease(askpassLeaseId);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
await fs.promises.rm(tempDir, { recursive: true, force: true }).catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
generateFidoSshKeyPair,
|
||||
resolveSshKeygenBinary,
|
||||
};
|
||||
@@ -352,7 +352,8 @@ function createMonitorService({
|
||||
historyId,
|
||||
status: entry?.status ?? host.status ?? "limit",
|
||||
at: shared?.at ?? 0,
|
||||
...(append && last?.historyId === historyId && last.statsAt === (shared?.at ?? 0)
|
||||
statsAt: shared?.statsAt ?? shared?.at ?? 0,
|
||||
...(append && last?.historyId === historyId && last.statsAt === (shared?.statsAt ?? shared?.at ?? 0)
|
||||
? {}
|
||||
: { stats: shared?.result?.success ? shared.result.stats : null }),
|
||||
points: append && last?.historyId === historyId
|
||||
|
||||
@@ -280,7 +280,7 @@ test("full terminal readers upgrade a lightweight cached sample and keep subsequ
|
||||
memTotal: 100,
|
||||
memUsed: null,
|
||||
topProcesses: ctx._monitorStats ? [] : [{ name: "full-process" }],
|
||||
gpu: ctx._monitorStats ? null : 75,
|
||||
gpu: 75,
|
||||
},
|
||||
};
|
||||
};
|
||||
@@ -752,3 +752,23 @@ test("snapshot deltas send new points only, even with 256 full retained historie
|
||||
assert.ok(Object.entries(fresh.hosts).every(([id, host]) => id === "h0" || !Object.hasOwn(host, "stats")));
|
||||
f.service.dispose();
|
||||
});
|
||||
|
||||
test('a completed GPU probe reaches incremental snapshots before the next CPU sample',async()=>{
|
||||
const {cacheFullGpuStats}=require('./sshBridge/monitorGpuStats.cjs');
|
||||
const f=fixture();
|
||||
try {
|
||||
f.service.reconcile(event(1),[{...demand(),interval:60}]);
|
||||
await until(()=>f.service.snapshot(event(1)).hosts.h?.status==='live');
|
||||
const initial=f.service.snapshot(event(1));
|
||||
const host=initial.hosts.h;
|
||||
const cursor={runtimeId:initial.runtimeId,epoch:initial.epoch,after:{h:{at:host.points.at(-1)?.at??0,statsAt:host.statsAt,historyId:host.historyId}}};
|
||||
cacheFullGpuStats(f.c,{gpu:75,gpuName:'NVIDIA independent',gpuMemUsed:512,gpuMemTotal:8192});
|
||||
const next=f.service.snapshot(event(1),cursor).hosts.h;
|
||||
assert.equal(next.stats.gpu,75,'GPU data must not be omitted by the primary sample cursor');
|
||||
assert.equal(next.at,host.at,'GPU publication must not change CPU freshness');
|
||||
assert.deepEqual(next.points,[],'GPU publication must not add trend samples');
|
||||
assert.ok(next.statsAt>host.statsAt);
|
||||
const caughtUp={...cursor,after:{h:{...cursor.after.h,statsAt:next.statsAt}}};
|
||||
assert.equal(f.service.snapshot(event(1),caughtUp).hosts.h.stats,undefined,'unchanged GPU data is not repeatedly sent');
|
||||
} finally {f.service.dispose();}
|
||||
});
|
||||
@@ -1405,13 +1405,19 @@ async function startPortForward(event, payload) {
|
||||
let defaultKeys = [];
|
||||
let portForwardAuthPhase = { hadPartialSuccess: false, passwordAlreadySucceeded: false };
|
||||
let authBanner = "";
|
||||
/** @type {(() => void)|null} */
|
||||
let releasePreparedFidoAgent = null;
|
||||
try {
|
||||
const fallbackAgentSocket = useSshAgent === false
|
||||
? null
|
||||
: useSshAgent === true
|
||||
? undefined
|
||||
: await getAvailableAgentSocket(identityAgent, { hostname, port, username });
|
||||
const systemAuthAgent = hasCertificate ? null : await prepareSystemSshAgentForAuth({
|
||||
const {
|
||||
enhanceAuthOptionsForFido,
|
||||
looksLikeSkOpenSshMaterial,
|
||||
} = require("./sshAuthHelper.cjs");
|
||||
const prep = await enhanceAuthOptionsForFido({
|
||||
useSshAgent,
|
||||
agentPublicKeys,
|
||||
identityAgent,
|
||||
@@ -1419,10 +1425,24 @@ async function startPortForward(event, payload) {
|
||||
identitiesOnly,
|
||||
addKeysToAgent,
|
||||
useKeychain,
|
||||
privateKey,
|
||||
certificate,
|
||||
hostname,
|
||||
port,
|
||||
username,
|
||||
}, "[PortForward]");
|
||||
}, sender);
|
||||
const isFido = prep.useFidoAgent === true;
|
||||
const systemAuthAgent = (hasCertificate && !isFido) ? null : await prepareSystemSshAgentForAuth(
|
||||
prep,
|
||||
"[PortForward]",
|
||||
);
|
||||
// Attach immediately so cancelTunnel ending `conn` during/after prepare
|
||||
// still drops the owned FIDO agent + askpass lease. Also keep a direct
|
||||
// handle for cancel-before-connect returns (close may have fired before
|
||||
// this listener existed).
|
||||
releasePreparedFidoAgent = systemAuthAgent
|
||||
? require("./attachFidoAgentRelease.cjs").attachFidoAgentRelease(conn, systemAuthAgent)
|
||||
: null;
|
||||
const identityFile = !privateKey && !systemAuthAgent
|
||||
? await loadFirstIdentityFileForAuth({
|
||||
sender,
|
||||
@@ -1436,7 +1456,9 @@ async function startPortForward(event, payload) {
|
||||
},
|
||||
})
|
||||
: null;
|
||||
const inlineKey = privateKey && !systemAuthAgent
|
||||
const inlineKey = privateKey
|
||||
&& !systemAuthAgent
|
||||
&& !looksLikeSkOpenSshMaterial(privateKey)
|
||||
? await preparePrivateKeyForAuth({
|
||||
sender,
|
||||
privateKey,
|
||||
@@ -1452,6 +1474,7 @@ async function startPortForward(event, payload) {
|
||||
const effectivePassphrase = inlineKey?.passphrase || identityFile?.passphrase;
|
||||
|
||||
if (isTunnelCancelled(tunnelState)) {
|
||||
try { releasePreparedFidoAgent?.(); } catch { /* ignore */ }
|
||||
portForwardingTunnels.delete(tunnelId);
|
||||
abandonPendingDial("Port forward connection cancelled");
|
||||
return { tunnelId, success: false, cancelled: true };
|
||||
@@ -1460,7 +1483,7 @@ async function startPortForward(event, payload) {
|
||||
if (systemAuthAgent) {
|
||||
connectOpts.agent = systemAuthAgent;
|
||||
}
|
||||
if (hasCertificate) {
|
||||
if (hasCertificate && !isFido) {
|
||||
connectOpts.agent = new NetcattyAgent({
|
||||
mode: "certificate",
|
||||
webContents: sender,
|
||||
@@ -1471,7 +1494,7 @@ async function startPortForward(event, payload) {
|
||||
passphrase: effectivePassphrase,
|
||||
},
|
||||
});
|
||||
} else if (effectivePrivateKey) {
|
||||
} else if (effectivePrivateKey && !isFido) {
|
||||
connectOpts.privateKey = effectivePrivateKey;
|
||||
if (effectivePassphrase) {
|
||||
connectOpts.passphrase = effectivePassphrase;
|
||||
@@ -1488,6 +1511,7 @@ async function startPortForward(event, payload) {
|
||||
? []
|
||||
: discoveredDefaultKeys;
|
||||
if (isTunnelCancelled(tunnelState)) {
|
||||
try { releasePreparedFidoAgent?.(); } catch { /* ignore */ }
|
||||
portForwardingTunnels.delete(tunnelId);
|
||||
abandonPendingDial("Port forward connection cancelled");
|
||||
return { tunnelId, success: false, cancelled: true };
|
||||
@@ -1510,6 +1534,7 @@ async function startPortForward(event, payload) {
|
||||
applyAuthToConnOpts(connectOpts, authConfig);
|
||||
portForwardAuthPhase = authConfig.authPhase || portForwardAuthPhase;
|
||||
if (isTunnelCancelled(tunnelState)) {
|
||||
try { releasePreparedFidoAgent?.(); } catch { /* ignore */ }
|
||||
portForwardingTunnels.delete(tunnelId);
|
||||
abandonPendingDial("Port forward connection cancelled");
|
||||
return { tunnelId, success: false, cancelled: true };
|
||||
@@ -1556,6 +1581,7 @@ async function startPortForward(event, payload) {
|
||||
chainConnections = chainResult.connections;
|
||||
tunnelState.chainConnections = chainConnections;
|
||||
if (isTunnelCancelled(tunnelState)) {
|
||||
try { releasePreparedFidoAgent?.(); } catch { /* ignore */ }
|
||||
cleanupChainConnections(chainConnections);
|
||||
if (!tunnelState.cleanupFailed) {
|
||||
portForwardingTunnels.delete(tunnelId);
|
||||
@@ -1574,6 +1600,7 @@ async function startPortForward(event, payload) {
|
||||
},
|
||||
});
|
||||
if (isTunnelCancelled(tunnelState)) {
|
||||
try { releasePreparedFidoAgent?.(); } catch { /* ignore */ }
|
||||
try { connectionSocket?.end?.(); } catch { /* ignore */ }
|
||||
try { connectionSocket?.destroy?.(); } catch { /* ignore */ }
|
||||
if (!tunnelState.cleanupFailed) {
|
||||
@@ -1589,6 +1616,7 @@ async function startPortForward(event, payload) {
|
||||
}
|
||||
} catch (err) {
|
||||
if (isTunnelCancelled(tunnelState)) {
|
||||
try { releasePreparedFidoAgent?.(); } catch { /* ignore */ }
|
||||
if (!tunnelState.cleanupFailed) {
|
||||
portForwardingTunnels.delete(tunnelId);
|
||||
}
|
||||
@@ -1601,10 +1629,12 @@ async function startPortForward(event, payload) {
|
||||
} catch {
|
||||
/* best-effort cancel on passphrase cancel */
|
||||
}
|
||||
try { releasePreparedFidoAgent?.(); } catch { /* ignore */ }
|
||||
abandonPendingDial(err);
|
||||
return { tunnelId, success: false, cancelled: true };
|
||||
}
|
||||
tunnelState.cancelled = true;
|
||||
try { releasePreparedFidoAgent?.(); } catch { /* ignore */ }
|
||||
if (tunnelState.pendingConn) {
|
||||
try { tunnelState.pendingConn.end(); } catch { /* ignore */ }
|
||||
}
|
||||
|
||||
@@ -245,9 +245,15 @@ function createOpenConnectionApi(ctx) {
|
||||
const hasCertificate =
|
||||
typeof jump.certificate === "string" && jump.certificate.trim().length > 0;
|
||||
|
||||
const systemAuthAgent = hasCertificate
|
||||
const { enhanceAuthOptionsForFido, looksLikeSkOpenSshMaterial } = require("../sshAuthHelper.cjs");
|
||||
const prep = await enhanceAuthOptionsForFido(jump, sender);
|
||||
const isFido = prep.useFidoAgent === true;
|
||||
const systemAuthAgent = (hasCertificate && !isFido)
|
||||
? null
|
||||
: await prepareSystemSshAgentForAuth(jump, `[SFTP Chain] Hop ${i + 1}:`);
|
||||
: await prepareSystemSshAgentForAuth(
|
||||
prep,
|
||||
`[SFTP Chain] Hop ${i + 1}:`,
|
||||
);
|
||||
|
||||
const identityFile = !jump.privateKey && !systemAuthAgent
|
||||
? await loadFirstIdentityFileForAuth({
|
||||
@@ -264,7 +270,9 @@ function createOpenConnectionApi(ctx) {
|
||||
},
|
||||
})
|
||||
: null;
|
||||
const inlineKey = jump.privateKey && !systemAuthAgent
|
||||
const inlineKey = jump.privateKey
|
||||
&& !systemAuthAgent
|
||||
&& !looksLikeSkOpenSshMaterial(jump.privateKey)
|
||||
? await preparePrivateKeyForAuth({
|
||||
sender,
|
||||
privateKey: jump.privateKey,
|
||||
@@ -281,8 +289,9 @@ function createOpenConnectionApi(ctx) {
|
||||
let authAgent = null;
|
||||
if (systemAuthAgent) {
|
||||
connOpts.agent = systemAuthAgent;
|
||||
require("../attachFidoAgentRelease.cjs").attachFidoAgentRelease(conn, systemAuthAgent);
|
||||
}
|
||||
if (hasCertificate) {
|
||||
if (hasCertificate && !isFido) {
|
||||
authAgent = new NetcattyAgent({
|
||||
mode: "certificate",
|
||||
webContents: event.sender,
|
||||
@@ -294,7 +303,7 @@ function createOpenConnectionApi(ctx) {
|
||||
},
|
||||
});
|
||||
connOpts.agent = authAgent;
|
||||
} else if (effectivePrivateKey) {
|
||||
} else if (effectivePrivateKey && !isFido) {
|
||||
connOpts.privateKey = effectivePrivateKey;
|
||||
if (effectivePassphrase) {
|
||||
connOpts.passphrase = effectivePassphrase;
|
||||
@@ -999,10 +1008,17 @@ function createOpenConnectionApi(ctx) {
|
||||
let identityFile = null;
|
||||
let inlineKey = null;
|
||||
let systemAuthAgent = null;
|
||||
let isFido = false;
|
||||
try {
|
||||
systemAuthAgent = hasCertificate
|
||||
const { enhanceAuthOptionsForFido, looksLikeSkOpenSshMaterial } = require("../sshAuthHelper.cjs");
|
||||
const prep = await enhanceAuthOptionsForFido(options, event.sender);
|
||||
isFido = prep.useFidoAgent === true;
|
||||
systemAuthAgent = (hasCertificate && !isFido)
|
||||
? null
|
||||
: await prepareSystemSshAgentForAuth(options, "[SFTP]");
|
||||
: await prepareSystemSshAgentForAuth(
|
||||
prep,
|
||||
"[SFTP]",
|
||||
);
|
||||
identityFile = !options.privateKey && !systemAuthAgent
|
||||
? await loadFirstIdentityFileForAuth({
|
||||
sender: event.sender,
|
||||
@@ -1018,7 +1034,9 @@ function createOpenConnectionApi(ctx) {
|
||||
},
|
||||
})
|
||||
: null;
|
||||
inlineKey = options.privateKey && !systemAuthAgent
|
||||
inlineKey = options.privateKey
|
||||
&& !systemAuthAgent
|
||||
&& !looksLikeSkOpenSshMaterial(options.privateKey)
|
||||
? await preparePrivateKeyForAuth({
|
||||
sender: event.sender,
|
||||
privateKey: options.privateKey,
|
||||
@@ -1040,7 +1058,7 @@ function createOpenConnectionApi(ctx) {
|
||||
if (systemAuthAgent) {
|
||||
connectOpts.agent = systemAuthAgent;
|
||||
}
|
||||
if (hasCertificate) {
|
||||
if (hasCertificate && !isFido) {
|
||||
authAgent = new NetcattyAgent({
|
||||
mode: "certificate",
|
||||
webContents: event.sender,
|
||||
@@ -1052,7 +1070,7 @@ function createOpenConnectionApi(ctx) {
|
||||
},
|
||||
});
|
||||
connectOpts.agent = authAgent;
|
||||
} else if (effectivePrivateKey) {
|
||||
} else if (effectivePrivateKey && !isFido) {
|
||||
connectOpts.privateKey = effectivePrivateKey;
|
||||
if (effectivePassphrase) {
|
||||
connectOpts.passphrase = effectivePassphrase;
|
||||
@@ -1150,7 +1168,10 @@ function createOpenConnectionApi(ctx) {
|
||||
// By connecting directly, we can filter these non-fatal errors and allow
|
||||
// the auth flow to continue to keyboard-interactive/password/etc.
|
||||
const sshClient = client.client;
|
||||
|
||||
if (systemAuthAgent) {
|
||||
require("../attachFidoAgentRelease.cjs").attachFidoAgentRelease(sshClient, systemAuthAgent);
|
||||
}
|
||||
|
||||
await new Promise((resolve, reject) => {
|
||||
let settled = false;
|
||||
let authReadyTimer = null;
|
||||
|
||||
@@ -532,17 +532,18 @@ describe("AI/MCP SCP transfer abort on shipped download/upload entry points", ()
|
||||
await fs.promises.writeFile(path.join(secondDir, "target.bin"), secondOriginal);
|
||||
await fs.promises.symlink(path.basename(firstDir), parentLink);
|
||||
|
||||
const originalRename = fs.promises.rename;
|
||||
let renameCalls = 0;
|
||||
fs.promises.rename = async (...args) => {
|
||||
await originalRename(...args);
|
||||
renameCalls += 1;
|
||||
if (renameCalls === 1) {
|
||||
await fs.promises.unlink(parentLink);
|
||||
await fs.promises.symlink(path.basename(secondDir), parentLink);
|
||||
}
|
||||
};
|
||||
t.after(() => { fs.promises.rename = originalRename; });
|
||||
// Preparation renames on the same filesystem and copies across devices.
|
||||
for (const operation of ["rename", "copyFile"]) {
|
||||
const original = fs.promises[operation];
|
||||
fs.promises[operation] = async (...args) => {
|
||||
await original(...args);
|
||||
if (String(args[1]).endsWith(".ready")) {
|
||||
await fs.promises.unlink(parentLink);
|
||||
await fs.promises.symlink(path.basename(secondDir), parentLink);
|
||||
}
|
||||
};
|
||||
t.after(() => { fs.promises[operation] = original; });
|
||||
}
|
||||
|
||||
await assert.rejects(
|
||||
() => sftpBridge.downloadSftpToLocal(null, {
|
||||
@@ -618,6 +619,9 @@ describe("AI/MCP SCP transfer abort on shipped download/upload entry points", ()
|
||||
},
|
||||
async downloadFile(_remotePath, localPath) {
|
||||
await fs.promises.writeFile(localPath, downloaded, { mode: 0o644 });
|
||||
// Unified transfers pre-create their stage as 0600. writeFile's mode
|
||||
// only applies to creation; make a real mode change necessary here.
|
||||
await fs.promises.chmod(localPath, 0o644);
|
||||
return { fileSize: downloaded.length, transferred: downloaded.length };
|
||||
},
|
||||
};
|
||||
@@ -661,6 +665,7 @@ describe("AI/MCP SCP transfer abort on shipped download/upload entry points", ()
|
||||
},
|
||||
async downloadFile(_remotePath, localPath) {
|
||||
await fs.promises.writeFile(localPath, downloaded, { mode: 0o644 });
|
||||
await fs.promises.chmod(localPath, 0o644);
|
||||
return { fileSize: downloaded.length, transferred: downloaded.length };
|
||||
},
|
||||
};
|
||||
@@ -710,6 +715,7 @@ describe("AI/MCP SCP transfer abort on shipped download/upload entry points", ()
|
||||
},
|
||||
async downloadFile(_remotePath, localPath) {
|
||||
await fs.promises.writeFile(localPath, downloaded, { mode: 0o644 });
|
||||
await fs.promises.chmod(localPath, 0o644);
|
||||
return { fileSize: downloaded.length, transferred: downloaded.length };
|
||||
},
|
||||
};
|
||||
@@ -773,11 +779,9 @@ describe("AI/MCP SCP transfer abort on shipped download/upload entry points", ()
|
||||
fs.writeFileSync(localPath, original);
|
||||
|
||||
const originalRename = fs.promises.rename;
|
||||
let renameCalls = 0;
|
||||
fs.promises.rename = async (...args) => {
|
||||
await originalRename(...args);
|
||||
renameCalls += 1;
|
||||
if (renameCalls === 2) controller.abort();
|
||||
if (String(args[1]).endsWith(".backup")) controller.abort();
|
||||
};
|
||||
t.after(() => { fs.promises.rename = originalRename; });
|
||||
|
||||
@@ -907,15 +911,13 @@ describe("AI/MCP SCP transfer abort on shipped download/upload entry points", ()
|
||||
fs.writeFileSync(localPath, Buffer.from("existing-local-content"));
|
||||
|
||||
const originalRename = fs.promises.rename;
|
||||
let renameCalls = 0;
|
||||
let readyPath = null;
|
||||
let backupPath = null;
|
||||
fs.promises.rename = async (...args) => {
|
||||
renameCalls += 1;
|
||||
await originalRename(...args);
|
||||
if (renameCalls === 1) readyPath = args[1];
|
||||
if (renameCalls === 2) {
|
||||
if (String(args[1]).endsWith(".backup")) {
|
||||
backupPath = args[1];
|
||||
readyPath = backupPath.replace(/\.backup$/, ".ready");
|
||||
controller.abort();
|
||||
}
|
||||
};
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
"use strict";
|
||||
|
||||
const test = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const {
|
||||
parseKey,
|
||||
getOpenSSHCertSignatureAlgorithm,
|
||||
} = require("ssh2/lib/protocol/keyParser.js");
|
||||
|
||||
function buildSkEd25519PublicLine() {
|
||||
const type = Buffer.from("sk-ssh-ed25519@openssh.com");
|
||||
const pub = Buffer.alloc(32, 9);
|
||||
const app = Buffer.from("ssh:");
|
||||
const parts = [type, pub, app];
|
||||
let len = 0;
|
||||
for (const part of parts) len += 4 + part.length;
|
||||
const buf = Buffer.alloc(len);
|
||||
let offset = 0;
|
||||
for (const part of parts) {
|
||||
buf.writeUInt32BE(part.length, offset);
|
||||
offset += 4;
|
||||
part.copy(buf, offset);
|
||||
offset += part.length;
|
||||
}
|
||||
return `sk-ssh-ed25519@openssh.com ${buf.toString("base64")} netcatty@sk`;
|
||||
}
|
||||
|
||||
function buildSkEcdsaPublicLine() {
|
||||
// Uncompressed P-256 point: 0x04 || X(32) || Y(32)
|
||||
const point = Buffer.alloc(65, 0);
|
||||
point[0] = 0x04;
|
||||
point[64] = 1;
|
||||
const type = Buffer.from("sk-ecdsa-sha2-nistp256@openssh.com");
|
||||
const curve = Buffer.from("nistp256");
|
||||
const app = Buffer.from("ssh:");
|
||||
const parts = [type, curve, point, app];
|
||||
let len = 0;
|
||||
for (const part of parts) len += 4 + part.length;
|
||||
const buf = Buffer.alloc(len);
|
||||
let offset = 0;
|
||||
for (const part of parts) {
|
||||
buf.writeUInt32BE(part.length, offset);
|
||||
offset += 4;
|
||||
part.copy(buf, offset);
|
||||
offset += part.length;
|
||||
}
|
||||
return `sk-ecdsa-sha2-nistp256@openssh.com ${buf.toString("base64")} netcatty@sk`;
|
||||
}
|
||||
|
||||
test("ssh2 parseKey accepts sk-ssh-ed25519 public keys", () => {
|
||||
const key = parseKey(buildSkEd25519PublicLine());
|
||||
assert.equal(key instanceof Error, false, key instanceof Error ? key.message : "");
|
||||
assert.equal(key.type, "sk-ssh-ed25519@openssh.com");
|
||||
assert.equal(typeof key.getPublicSSH, "function");
|
||||
assert.equal(key.getApplication?.(), "ssh:");
|
||||
const signErr = key.sign(Buffer.from("challenge"));
|
||||
assert.equal(signErr instanceof Error, true);
|
||||
assert.match(String(signErr.message), /security-key|ssh-agent|FIDO/i);
|
||||
});
|
||||
|
||||
test("ssh2 parseKey accepts sk-ecdsa-sha2-nistp256 public keys", () => {
|
||||
const key = parseKey(buildSkEcdsaPublicLine());
|
||||
assert.equal(key instanceof Error, false, key instanceof Error ? key.message : "");
|
||||
assert.equal(key.type, "sk-ecdsa-sha2-nistp256@openssh.com");
|
||||
assert.equal(key.getApplication?.(), "ssh:");
|
||||
});
|
||||
|
||||
function buildSkEd25519CertPublicLine() {
|
||||
// Minimal OpenSSH SK cert wire shape: type, nonce, then SK public fields.
|
||||
const type = Buffer.from("sk-ssh-ed25519-cert-v01@openssh.com");
|
||||
const nonce = Buffer.alloc(16, 7);
|
||||
const pub = Buffer.alloc(32, 9);
|
||||
const app = Buffer.from("ssh:");
|
||||
const parts = [type, nonce, pub, app];
|
||||
let len = 0;
|
||||
for (const part of parts) len += 4 + part.length;
|
||||
const buf = Buffer.alloc(len);
|
||||
let offset = 0;
|
||||
for (const part of parts) {
|
||||
buf.writeUInt32BE(part.length, offset);
|
||||
offset += 4;
|
||||
part.copy(buf, offset);
|
||||
offset += part.length;
|
||||
}
|
||||
return `sk-ssh-ed25519-cert-v01@openssh.com ${buf.toString("base64")} netcatty@sk-cert`;
|
||||
}
|
||||
|
||||
test("ssh2 parseKey accepts OpenSSH-order SK certificate public keys", () => {
|
||||
const line = buildSkEd25519CertPublicLine();
|
||||
const certBlob = Buffer.from(line.split(/\s+/)[1], "base64");
|
||||
const key = parseKey(line);
|
||||
assert.equal(key instanceof Error, false, key instanceof Error ? key.message : "");
|
||||
assert.equal(key.type, "sk-ssh-ed25519-cert-v01@openssh.com");
|
||||
assert.equal(key.getApplication?.(), "ssh:");
|
||||
assert.deepEqual(key.getPublicSSH(), certBlob);
|
||||
});
|
||||
|
||||
test("ssh2 maps SK certificate identities to full wire signature algorithms", () => {
|
||||
assert.equal(
|
||||
getOpenSSHCertSignatureAlgorithm("sk-ssh-ed25519-cert-v01@openssh.com"),
|
||||
"sk-ssh-ed25519@openssh.com",
|
||||
);
|
||||
assert.equal(
|
||||
getOpenSSHCertSignatureAlgorithm("sk-ecdsa-sha2-nistp256-cert-v01@openssh.com"),
|
||||
"sk-ecdsa-sha2-nistp256@openssh.com",
|
||||
);
|
||||
assert.equal(
|
||||
getOpenSSHCertSignatureAlgorithm("rsa-sha2-256-cert-v01@openssh.com"),
|
||||
"rsa-sha2-256",
|
||||
);
|
||||
assert.equal(
|
||||
getOpenSSHCertSignatureAlgorithm("ecdsa-sha2-nistp256-cert-v01@openssh.com"),
|
||||
"ecdsa-sha2-nistp256",
|
||||
);
|
||||
assert.equal(
|
||||
getOpenSSHCertSignatureAlgorithm("sk-ssh-ed25519@openssh.com"),
|
||||
"sk-ssh-ed25519@openssh.com",
|
||||
);
|
||||
});
|
||||
|
||||
test("ssh2 agent protocol preserves SK signature framing, flags and counter", async () => {
|
||||
const { AgentProtocol } = require("ssh2/lib/agent.js");
|
||||
for (const line of [buildSkEd25519PublicLine(), buildSkEcdsaPublicLine(), buildSkEd25519CertPublicLine()]) {
|
||||
const client = new AgentProtocol(true);
|
||||
const server = new AgentProtocol(false);
|
||||
client.pipe(server).pipe(client);
|
||||
const raw = Buffer.alloc(64, 7);
|
||||
const payload = Buffer.alloc(4 + raw.length + 5);
|
||||
payload.writeUInt32BE(raw.length, 0);
|
||||
raw.copy(payload, 4);
|
||||
payload[4 + raw.length] = 5; // presence and verification
|
||||
payload.writeUInt32BE(0x12345678, 5 + raw.length);
|
||||
server.on("sign", (request, key) => {
|
||||
assert.equal(request.getContext(), getOpenSSHCertSignatureAlgorithm(key.type));
|
||||
server.signReply(request, payload);
|
||||
});
|
||||
try {
|
||||
const returned = await new Promise((resolve, reject) => client.sign(
|
||||
parseKey(line), Buffer.from("challenge"), {},
|
||||
(error, signature) => error ? reject(error) : resolve(signature),
|
||||
));
|
||||
assert.deepEqual(returned, payload);
|
||||
} finally {
|
||||
client.destroy();
|
||||
server.destroy();
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -7,9 +7,10 @@ const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
const os = require("node:os");
|
||||
const { createHash } = require("node:crypto");
|
||||
const { exec } = require("node:child_process");
|
||||
const childProcess = require("node:child_process");
|
||||
const { exec } = childProcess;
|
||||
const { utils: sshUtils } = require("ssh2");
|
||||
const { prepareSystemSshAgent } = require("./systemSshAgent.cjs");
|
||||
const systemSshAgent = require("./systemSshAgent.cjs");
|
||||
const keyboardInteractiveHandler = require("./keyboardInteractiveHandler.cjs");
|
||||
const passphraseHandler = require("./passphraseHandler.cjs");
|
||||
const {
|
||||
@@ -142,10 +143,8 @@ function isKeyEncrypted(keyContent) {
|
||||
return false;
|
||||
}
|
||||
|
||||
function expandIdentityFilePath(keyPath) {
|
||||
return keyPath.startsWith("~/")
|
||||
? path.join(os.homedir(), keyPath.slice(2))
|
||||
: keyPath;
|
||||
function expandIdentityFilePath(keyPath, context = {}) {
|
||||
return systemSshAgent.resolveIdentityPath(keyPath, context);
|
||||
}
|
||||
|
||||
function notifyPassphraseAuthFailed(sender, keyPath, resolvedPath, keyIds) {
|
||||
@@ -900,31 +899,550 @@ async function getNativeOpenSshForwardingAgentSocket(identityAgent, injected = {
|
||||
return socketPath;
|
||||
}
|
||||
|
||||
async function prepareSystemSshAgentForAuth(options, logPrefix = "[SSHAuth]") {
|
||||
if (options?.useSshAgent !== true) return null;
|
||||
const socketPath = await getAvailableAgentSocket(options.identityAgent, {
|
||||
hostname: options.hostname,
|
||||
port: options.port,
|
||||
username: options.username,
|
||||
});
|
||||
if (!socketPath) {
|
||||
const error = new Error("System SSH agent is unavailable. Start or unlock it, or configure a valid agent socket.");
|
||||
error.code = "ERR_SSH_AGENT_UNAVAILABLE";
|
||||
const OPENSSH_PRIVATE_KEY_RE =
|
||||
/-----BEGIN OPENSSH PRIVATE KEY-----([\s\S]+?)-----END OPENSSH PRIVATE KEY-----/;
|
||||
const SK_SSH_ED25519 = "sk-ssh-ed25519@openssh.com";
|
||||
const SK_ECDSA_NISTP256 = "sk-ecdsa-sha2-nistp256@openssh.com";
|
||||
|
||||
/**
|
||||
* Detect OpenSSH FIDO sk-* material in public keys *or* private-key PEMs.
|
||||
*
|
||||
* Real sk private PEMs only embed `sk-*-@openssh.com` inside the base64 body
|
||||
* (`@` is not in the base64 alphabet), so a raw-text regex on the PEM fails.
|
||||
* Mirror domain/fidoSsh.ts isSkPrivateKey: decode the PEM body first.
|
||||
*/
|
||||
function looksLikeSkOpenSshMaterial(text) {
|
||||
if (typeof text !== "string" || !text.trim()) return false;
|
||||
// Public keys / agent blobs / already-decoded comments keep the type in plain text.
|
||||
// Include sk-*-cert-v01@openssh.com certificate algorithms.
|
||||
if (/sk-(?:ssh-ed25519|ecdsa-sha2-nistp256)(?:-cert-v0[01])?@openssh\.com/.test(text)) return true;
|
||||
const match = OPENSSH_PRIVATE_KEY_RE.exec(text);
|
||||
if (!match) return false;
|
||||
try {
|
||||
const body = Buffer.from(match[1].replace(/\s+/g, ""), "base64").toString("binary");
|
||||
return body.includes(SK_SSH_ED25519) || body.includes(SK_ECDSA_NISTP256)
|
||||
|| /sk-(?:ssh-ed25519|ecdsa-sha2-nistp256)(?:-cert-v0[01])?@openssh\.com/.test(body);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Peek IdentityFile paths for sk-* public/private material so path-only
|
||||
* reference keys still force the owned FIDO agent + askpass path.
|
||||
* @param {string[]} identityFilePaths
|
||||
* @param {{ readFile?: typeof fs.promises.readFile, expandIdentityFilePath?: Function }} [injected]
|
||||
*/
|
||||
async function identityFilesLookLikeSk(identityFilePaths, injected = {}) {
|
||||
if (!Array.isArray(identityFilePaths) || identityFilePaths.length === 0) return false;
|
||||
const readFile = injected.readFile || fs.promises.readFile;
|
||||
const expand = injected.expandIdentityFilePath || expandIdentityFilePath;
|
||||
for (const rawPath of identityFilePaths) {
|
||||
const identityPath = expand(rawPath, injected);
|
||||
if (!identityPath) continue;
|
||||
const candidates = identityPath.endsWith(".pub")
|
||||
? [identityPath]
|
||||
: [`${identityPath}.pub`, identityPath];
|
||||
for (const candidate of candidates) {
|
||||
try {
|
||||
const contents = await readFile(candidate, "utf8");
|
||||
if (looksLikeSkOpenSshMaterial(contents)) return true;
|
||||
} catch {
|
||||
// try next candidate
|
||||
}
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function resolveWebContentsFromSender(sender) {
|
||||
return () => {
|
||||
try {
|
||||
return sender && !sender.isDestroyed?.() ? sender : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/** True when options carry FIDO2 sk-* material that needs agent + askpass. */
|
||||
function isFidoSkAuthOptions(options) {
|
||||
if (!options || typeof options !== "object") return false;
|
||||
if (options.useFidoAgent === true) return true;
|
||||
if (looksLikeSkOpenSshMaterial(options.privateKey)) return true;
|
||||
if (Array.isArray(options.agentPublicKeys)
|
||||
&& options.agentPublicKeys.some((key) => looksLikeSkOpenSshMaterial(key))) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize auth options for prepareSystemSshAgentForAuth so every surface
|
||||
* (SSH / SFTP / Mosh / ET / port-forward) gets askpass + FIDO agent force.
|
||||
* Sync: only inspects inline material. Prefer enhanceAuthOptionsForFido when
|
||||
* IdentityFile paths may hold sk-* handles without vault type markers.
|
||||
* @param {object} options
|
||||
* @param {Electron.WebContents} [sender]
|
||||
*/
|
||||
function buildFidoAwareAgentPrepOptions(options = {}, sender) {
|
||||
const isFido = isFidoSkAuthOptions(options) && options.authMethod !== "password";
|
||||
return {
|
||||
...options,
|
||||
useSshAgent: isFido ? true : options.useSshAgent,
|
||||
useFidoAgent: isFido || options.useFidoAgent === true,
|
||||
loadIdentityFilesIntoAgent: isFido || options.loadIdentityFilesIntoAgent === true,
|
||||
addKeysToAgent: options.addKeysToAgent || (isFido ? "yes" : options.addKeysToAgent),
|
||||
resolveWebContents: options.resolveWebContents || (sender
|
||||
? resolveWebContentsFromSender(sender)
|
||||
: options.resolveWebContents),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Async FIDO prep: also peeks IdentityFile paths so path-only SK keys force
|
||||
* the owned agent on every surface (not only startSession).
|
||||
*/
|
||||
async function enhanceAuthOptionsForFido(options = {}, sender) {
|
||||
const base = buildFidoAwareAgentPrepOptions(options, sender);
|
||||
if (base.useFidoAgent === true || base.authMethod === "password") return base;
|
||||
if (!Array.isArray(options.identityFilePaths) || options.identityFilePaths.length === 0) {
|
||||
return base;
|
||||
}
|
||||
try {
|
||||
if (await identityFilesLookLikeSk(options.identityFilePaths, options)) {
|
||||
return {
|
||||
...base,
|
||||
useSshAgent: true,
|
||||
useFidoAgent: true,
|
||||
loadIdentityFilesIntoAgent: true,
|
||||
addKeysToAgent: options.addKeysToAgent || "yes",
|
||||
};
|
||||
}
|
||||
} catch {
|
||||
// ignore probe failures
|
||||
}
|
||||
return base;
|
||||
}
|
||||
|
||||
/**
|
||||
* True when certificate auth must stay on the software NetcattyAgent path.
|
||||
* FIDO SK + certificate must keep the system/owned agent (hardware signs).
|
||||
*/
|
||||
function shouldUseSoftwareCertificateAgent(options = {}, isFidoSk = false) {
|
||||
const hasCertificate = typeof options.certificate === "string"
|
||||
&& options.certificate.trim().length > 0;
|
||||
return hasCertificate && !isFidoSk;
|
||||
}
|
||||
|
||||
/**
|
||||
* After prepareSystemSshAgentForAuth, resolve the socket path for native ssh
|
||||
* (Mosh/ET IdentityAgent / SSH_AUTH_SOCK). Prefers the owned FIDO agent.
|
||||
*/
|
||||
function resolvePreparedAgentSocket(agent, options = {}) {
|
||||
if (agent && typeof agent._netcattyAgentSocket === "string" && agent._netcattyAgentSocket) {
|
||||
return agent._netcattyAgentSocket;
|
||||
}
|
||||
try {
|
||||
const { getActiveFidoAgentSocket } = require("./fidoAgentManager.cjs");
|
||||
const owned = getActiveFidoAgentSocket();
|
||||
if (owned) return owned;
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Persist an inline FIDO2 sk-* private key handle so ssh-add can load it.
|
||||
* Returns the temp path (or null). Caller should clean up when done if needed;
|
||||
* agent retains the identity after add.
|
||||
*/
|
||||
function deriveSkPublicKeyLine(privateKey) {
|
||||
try {
|
||||
const parsed = sshUtils.parseKey(privateKey);
|
||||
if (parsed instanceof Error || typeof parsed?.getPublicSSH !== "function") return null;
|
||||
const type = typeof parsed.type === "string" ? parsed.type : "";
|
||||
const blob = parsed.getPublicSSH();
|
||||
if (!type || !blob) return null;
|
||||
return `${type} ${Buffer.from(blob).toString("base64")} netcatty-sk`;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function materializeSkPrivateKeyFile(privateKey, injected = {}) {
|
||||
if (!looksLikeSkOpenSshMaterial(privateKey)) return null;
|
||||
const fsMod = injected.fs || fs;
|
||||
const pathMod = injected.path || path;
|
||||
const tempDirBridge = injected.tempDirBridge || require("./tempDirBridge.cjs");
|
||||
// Fail closed: never stage SK credential handles under the OS temp directory.
|
||||
if (typeof tempDirBridge.getTempDir !== "function") {
|
||||
const err = new Error(
|
||||
"FIDO2 key staging requires Netcatty temp directory (tempDirBridge unavailable).",
|
||||
);
|
||||
err.code = "ERR_FIDO_TEMP_DIR_UNAVAILABLE";
|
||||
throw err;
|
||||
}
|
||||
const baseDir = tempDirBridge.getTempDir();
|
||||
const dir = await fsMod.promises.mkdtemp(pathMod.join(baseDir, "netcatty-sk-key-"));
|
||||
try {
|
||||
const keyPath = pathMod.join(dir, "id_sk");
|
||||
await fsMod.promises.writeFile(keyPath, privateKey, { mode: 0o600 });
|
||||
// Companion .pub lets shared-agent prep recognize an already-loaded identity
|
||||
// across unique per-session temp paths (refcount cleanup keys on the blob).
|
||||
const publicKeyHint = typeof injected.publicKey === "string" ? injected.publicKey.trim() : "";
|
||||
const publicKeyLine = publicKeyHint || deriveSkPublicKeyLine(privateKey);
|
||||
if (publicKeyLine) {
|
||||
await fsMod.promises.writeFile(`${keyPath}.pub`, `${publicKeyLine}\n`, { mode: 0o600 });
|
||||
}
|
||||
const certificate = typeof injected.certificate === "string" ? injected.certificate.trim() : "";
|
||||
if (certificate) {
|
||||
// OpenSSH ssh-add auto-loads `<key>-cert.pub` next to the private handle.
|
||||
await fsMod.promises.writeFile(`${keyPath}-cert.pub`, `${certificate}\n`, { mode: 0o600 });
|
||||
}
|
||||
return { keyPath, cleanupDir: dir };
|
||||
} catch (error) {
|
||||
await fsMod.promises.rm(dir, { recursive: true, force: true }).catch(() => {});
|
||||
throw error;
|
||||
}
|
||||
return prepareSystemSshAgent({
|
||||
socketPath,
|
||||
identityFilePaths: options.identityFilePaths,
|
||||
identitiesOnly: options.identitiesOnly,
|
||||
addKeysToAgent: options.addKeysToAgent,
|
||||
useKeychain: options.useKeychain,
|
||||
agentPublicKeys: options.agentPublicKeys,
|
||||
hostname: options.hostname,
|
||||
port: options.port,
|
||||
username: options.username,
|
||||
}, {
|
||||
log: (message, details) => console.log(`${logPrefix} ${message}`, details ?? ""),
|
||||
});
|
||||
}
|
||||
|
||||
async function prepareSystemSshAgentForAuth(options, logPrefix = "[SSHAuth]") {
|
||||
let pathBackedSk = false;
|
||||
// Password-only auth must never probe IdentityFiles for SK material — that
|
||||
// would spawn an owned agent and advertise publickey against the user's
|
||||
// explicit password choice.
|
||||
if (options.authMethod !== "password"
|
||||
&& !options.useFidoAgent
|
||||
&& !looksLikeSkOpenSshMaterial(options.privateKey)
|
||||
&& !(Array.isArray(options.agentPublicKeys)
|
||||
&& options.agentPublicKeys.some((key) => looksLikeSkOpenSshMaterial(key)))
|
||||
&& Array.isArray(options.identityFilePaths)
|
||||
&& options.identityFilePaths.length > 0) {
|
||||
try {
|
||||
pathBackedSk = await identityFilesLookLikeSk(options.identityFilePaths, options);
|
||||
} catch {
|
||||
pathBackedSk = false;
|
||||
}
|
||||
}
|
||||
|
||||
const isFidoFlow = looksLikeSkOpenSshMaterial(options.privateKey)
|
||||
|| (Array.isArray(options.agentPublicKeys)
|
||||
&& options.agentPublicKeys.some((key) => looksLikeSkOpenSshMaterial(key)))
|
||||
|| options.useFidoAgent === true
|
||||
|| options.loadIdentityFilesIntoAgent === true
|
||||
|| pathBackedSk;
|
||||
|
||||
// Path-backed / flagged FIDO keys imply agent auth even when the caller did
|
||||
// not already set useSshAgent (vault default for soft keys is often false).
|
||||
if (options?.useSshAgent !== true && !isFidoFlow) return null;
|
||||
|
||||
let socketPath = null;
|
||||
let askpassEnv = null;
|
||||
let ownedFidoAgent = false;
|
||||
/** @type {number|undefined} */
|
||||
let ownedFidoAgentGeneration;
|
||||
/** @type {string|null} */
|
||||
let fidoSshAddPath = null;
|
||||
/** True when acquireFidoAgent succeeded (owned or shared Windows pipe). */
|
||||
let acquiredFidoAgent = false;
|
||||
|
||||
if (isFidoFlow) {
|
||||
try {
|
||||
const { acquireFidoAgent } = require("./fidoAgentManager.cjs");
|
||||
// acquireFidoAgent allocates the askpass lease for this caller — do not
|
||||
// build a separate lease here or it would leak when overwritten.
|
||||
const fidoAgent = await acquireFidoAgent({
|
||||
resolveWebContents: options.resolveWebContents,
|
||||
env: process.env,
|
||||
});
|
||||
socketPath = fidoAgent.socketPath;
|
||||
askpassEnv = fidoAgent.askpassEnv || null;
|
||||
ownedFidoAgent = fidoAgent.owned === true;
|
||||
ownedFidoAgentGeneration = fidoAgent.generation;
|
||||
fidoSshAddPath = typeof fidoAgent.sshAddPath === "string" ? fidoAgent.sshAddPath : null;
|
||||
acquiredFidoAgent = true;
|
||||
console.log(`${logPrefix} Using Netcatty FIDO ssh-agent`, {
|
||||
socketPath,
|
||||
owned: ownedFidoAgent,
|
||||
});
|
||||
} catch (error) {
|
||||
console.log(`${logPrefix} FIDO agent start failed; falling back to system agent`, {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (!socketPath) {
|
||||
socketPath = await getAvailableAgentSocket(options.identityAgent, {
|
||||
hostname: options.hostname,
|
||||
port: options.port,
|
||||
username: options.username,
|
||||
});
|
||||
}
|
||||
if (!socketPath) {
|
||||
const error = new Error(
|
||||
isFidoFlow
|
||||
? "FIDO2 SSH agent is unavailable. Install OpenSSH with libfido2 (macOS: brew install openssh libfido2) and try again."
|
||||
: "System SSH agent is unavailable. Start or unlock it, or configure a valid agent socket.",
|
||||
);
|
||||
error.code = isFidoFlow ? "ERR_FIDO_AGENT_UNAVAILABLE" : "ERR_SSH_AGENT_UNAVAILABLE";
|
||||
throw error;
|
||||
}
|
||||
|
||||
if (isFidoFlow && !askpassEnv) {
|
||||
try {
|
||||
const { buildFidoAskpassEnv } = require("./fidoAskpass.cjs");
|
||||
askpassEnv = buildFidoAskpassEnv({
|
||||
resolveWebContents: options.resolveWebContents,
|
||||
});
|
||||
} catch {
|
||||
askpassEnv = null;
|
||||
}
|
||||
}
|
||||
|
||||
const identityFilePaths = Array.isArray(options.identityFilePaths)
|
||||
? [...options.identityFilePaths]
|
||||
: [];
|
||||
const publicKeyHint = Array.isArray(options.agentPublicKeys)
|
||||
? options.agentPublicKeys.find((key) => looksLikeSkOpenSshMaterial(key))
|
||||
: null;
|
||||
let skTempCleanup = null;
|
||||
/** @type {string[]} */
|
||||
let newlyLoadedIdentityPaths = [];
|
||||
/** @type {Array<{ key: string, identityPath: string }>} */
|
||||
let sharedAgentIdentities = [];
|
||||
let fidoResourcesReleased = false;
|
||||
let nativeFidoAgent = null;
|
||||
const releaseAcquiredFidoResources = () => {
|
||||
if (fidoResourcesReleased) return;
|
||||
fidoResourcesReleased = true;
|
||||
nativeFidoAgent?.close();
|
||||
// Netcatty-owned agents outlive a single session (releaseFidoAgent keeps
|
||||
// the process alive while other sessions hold refs), just like the shared
|
||||
// Windows system agent. In both cases: drop our hold and only ssh-add -d
|
||||
// when this was the last session still using that public identity —
|
||||
// otherwise one session's SK identity stays advertised to the survivors
|
||||
// (and to any agent-forwarding delegate) after it closed.
|
||||
if (sharedAgentIdentities.length > 0 && socketPath) {
|
||||
const {
|
||||
resolveSshAddBinary,
|
||||
releaseSharedAgentIdentity,
|
||||
} = require("./systemSshAgent.cjs");
|
||||
const sshAdd = fidoSshAddPath || resolveSshAddBinary(process.platform, process.env);
|
||||
void systemSshAgent.withAgentIdentityLock(socketPath, async () => {
|
||||
for (const entry of sharedAgentIdentities) {
|
||||
const { shouldRemove, identityPath, cleanupDir, plain } = releaseSharedAgentIdentity(entry.key, socketPath);
|
||||
if (!shouldRemove) continue;
|
||||
if (identityPath) {
|
||||
// Keep all selectors until ssh-add finishes, and prevent a new
|
||||
// preparation from borrowing an identity that is being removed.
|
||||
await new Promise(resolve => {
|
||||
try {
|
||||
childProcess.execFile(sshAdd, plain ? ["-d", "-k", identityPath] : ["-d", identityPath], {
|
||||
timeout: 5000,
|
||||
windowsHide: true,
|
||||
env: { ...process.env, SSH_AUTH_SOCK: socketPath, SSH_ASKPASS_REQUIRE: "never" },
|
||||
}, () => resolve());
|
||||
} catch { resolve(); }
|
||||
});
|
||||
}
|
||||
const removable = systemSshAgent.finishSharedCleanupDir(cleanupDir);
|
||||
if (removable) await fs.promises.rm(removable, { recursive: true, force: true }).catch(() => {});
|
||||
}
|
||||
}).catch(() => {});
|
||||
}
|
||||
try {
|
||||
// Always drop the acquire refcount (owned child or Windows system-pipe slot).
|
||||
if (acquiredFidoAgent) {
|
||||
require("./fidoAgentManager.cjs").releaseFidoAgent(ownedFidoAgentGeneration);
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
try {
|
||||
const askpassLeaseId = askpassEnv?.NETCATTY_FIDO_ASKPASS_LEASE;
|
||||
if (askpassLeaseId) {
|
||||
require("./fidoAskpass.cjs").releaseFidoAskpassLease(askpassLeaseId);
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
};
|
||||
|
||||
try {
|
||||
if (looksLikeSkOpenSshMaterial(options.privateKey)) {
|
||||
try {
|
||||
const materialized = await materializeSkPrivateKeyFile(options.privateKey, {
|
||||
certificate: options.certificate,
|
||||
publicKey: publicKeyHint,
|
||||
});
|
||||
if (materialized?.keyPath) {
|
||||
identityFilePaths.push(materialized.keyPath);
|
||||
skTempCleanup = materialized.cleanupDir;
|
||||
}
|
||||
} catch (error) {
|
||||
console.log(`${logPrefix} Could not materialize FIDO2 key handle for agent load`, {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
} else if (
|
||||
isFidoFlow
|
||||
&& typeof options.certificate === "string"
|
||||
&& options.certificate.trim()
|
||||
&& Array.isArray(options.identityFilePaths)
|
||||
) {
|
||||
// Path-backed SK + vault certificate: copy handle into temp and stage
|
||||
// `<key>-cert.pub` so ssh-add advertises the certified identity. Never
|
||||
// mutate the user's IdentityFile tree.
|
||||
try {
|
||||
for (const rawPath of options.identityFilePaths) {
|
||||
const identityPath = expandIdentityFilePath(rawPath, options);
|
||||
if (!identityPath || identityPath.endsWith(".pub")) continue;
|
||||
let privateKeyText = "";
|
||||
try {
|
||||
privateKeyText = await fs.promises.readFile(identityPath, "utf8");
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
if (!looksLikeSkOpenSshMaterial(privateKeyText)) continue;
|
||||
const materialized = await materializeSkPrivateKeyFile(privateKeyText, {
|
||||
certificate: options.certificate,
|
||||
publicKey: publicKeyHint,
|
||||
});
|
||||
if (materialized?.keyPath) {
|
||||
identityFilePaths.push(materialized.keyPath);
|
||||
skTempCleanup = materialized.cleanupDir;
|
||||
}
|
||||
break;
|
||||
}
|
||||
} catch (error) {
|
||||
console.log(`${logPrefix} Could not stage FIDO certificate for agent load`, {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
const prepEnv = fidoSshAddPath
|
||||
? { ...process.env, NETCATTY_SSH_ADD_PATH: fidoSshAddPath }
|
||||
: process.env;
|
||||
return await systemSshAgent.withAgentIdentityLock(socketPath, async () => {
|
||||
const agent = await systemSshAgent.prepareSystemSshAgent({
|
||||
socketPath,
|
||||
identityFilePaths,
|
||||
identitiesOnly: options.identitiesOnly,
|
||||
addKeysToAgent: options.addKeysToAgent,
|
||||
useKeychain: ownedFidoAgent ? false : options.useKeychain,
|
||||
agentPublicKeys: isFidoFlow && options.certificate
|
||||
? [...(options.agentPublicKeys || []), options.certificate]
|
||||
: options.agentPublicKeys,
|
||||
loadIdentityFilesIntoAgent: looksLikeSkOpenSshMaterial(options.privateKey)
|
||||
|| (Array.isArray(options.agentPublicKeys)
|
||||
&& options.agentPublicKeys.some((key) => looksLikeSkOpenSshMaterial(key)))
|
||||
|| options.loadIdentityFilesIntoAgent === true
|
||||
|| ownedFidoAgent
|
||||
// Windows system-pipe fallback still needs SK handles loaded for ssh2.
|
||||
|| (!ownedFidoAgent && acquiredFidoAgent),
|
||||
hostname: options.hostname,
|
||||
port: options.port,
|
||||
username: options.username,
|
||||
}, {
|
||||
log: (message, details) => console.log(`${logPrefix} ${message}`, details ?? ""),
|
||||
askpassEnv,
|
||||
env: prepEnv,
|
||||
});
|
||||
if (agent) {
|
||||
// Expose the socket so Mosh/ET/native OpenSSH can point IdentityAgent at
|
||||
// the same agent that received ssh-add of SK handles.
|
||||
agent._netcattyAgentSocket = socketPath;
|
||||
newlyLoadedIdentityPaths = Array.isArray(agent._netcattyNewlyLoadedIdentityPaths)
|
||||
? agent._netcattyNewlyLoadedIdentityPaths
|
||||
: [];
|
||||
sharedAgentIdentities = Array.isArray(agent._netcattySharedAgentIdentities)
|
||||
? agent._netcattySharedAgentIdentities
|
||||
: newlyLoadedIdentityPaths.map((identityPath) => ({
|
||||
key: identityPath,
|
||||
identityPath,
|
||||
}));
|
||||
if (sharedAgentIdentities.length > 0) {
|
||||
// Reference-count identities for owned agents too: the owned agent is
|
||||
// a long-lived singleton, so closing one session must drop only that
|
||||
// session's hold, not leave its SK identity advertised to survivors.
|
||||
const { retainSharedAgentIdentity } = require("./systemSshAgent.cjs");
|
||||
let transferredSkTempCleanup = false;
|
||||
for (const entry of sharedAgentIdentities) {
|
||||
const cleanupDir = skTempCleanup
|
||||
&& typeof entry.identityPath === "string"
|
||||
&& entry.identityPath.startsWith(skTempCleanup)
|
||||
? skTempCleanup
|
||||
: null;
|
||||
const retained = retainSharedAgentIdentity(entry.key, entry.identityPath, cleanupDir, socketPath, entry.plain === true);
|
||||
// Only the first retainer for a public identity adopts staging cleanup.
|
||||
// Later sessions materialize a redundant temp dir that must stay on
|
||||
// skTempCleanup so the finally block can remove it immediately.
|
||||
if (retained?.acceptedCleanup) {
|
||||
transferredSkTempCleanup = true;
|
||||
}
|
||||
}
|
||||
if (transferredSkTempCleanup) {
|
||||
skTempCleanup = null;
|
||||
}
|
||||
}
|
||||
const askpassLeaseId = askpassEnv?.NETCATTY_FIDO_ASKPASS_LEASE;
|
||||
if (askpassLeaseId) {
|
||||
const { runWithFidoSigningLease } = require("./fidoAskpass.cjs");
|
||||
const sign = agent.sign.bind(agent);
|
||||
agent.sign = (key, data, signOptions, callback) => {
|
||||
if (typeof signOptions === "function") {
|
||||
callback = signOptions;
|
||||
signOptions = undefined;
|
||||
}
|
||||
runWithFidoSigningLease(askpassLeaseId, (finish, context) => {
|
||||
if (fidoResourcesReleased) {
|
||||
finish();
|
||||
callback(new Error("FIDO authentication session has ended"));
|
||||
return;
|
||||
}
|
||||
require("./fidoSignFlow.cjs").signWithRecovery(context, options,
|
||||
settle => sign(key, data, signOptions, settle)).then(result => {
|
||||
finish();
|
||||
callback(result.error, result.signature);
|
||||
}, error => { finish(); callback(error); });
|
||||
});
|
||||
};
|
||||
}
|
||||
if (options.nativeFidoAgent === true && isFidoFlow) {
|
||||
nativeFidoAgent = await require("./fidoNativeAgent.cjs").createNativeFidoAgent(agent, { sshBinaryPath: options.nativeSshPath });
|
||||
agent._netcattyAgentSocket = nativeFidoAgent.socketPath;
|
||||
}
|
||||
if (ownedFidoAgent || askpassLeaseId || sharedAgentIdentities.length > 0 || acquiredFidoAgent) {
|
||||
agent._netcattyOwnedFidoAgent = ownedFidoAgent === true;
|
||||
// Shared lease; quit/exit hooks hard-kill the owned agent as a backstop.
|
||||
agent._releaseNetcattyFidoAgent = releaseAcquiredFidoResources;
|
||||
}
|
||||
} else {
|
||||
// Acquisition succeeded but preparation produced no agent — drop leases.
|
||||
releaseAcquiredFidoResources();
|
||||
}
|
||||
return agent;
|
||||
});
|
||||
} catch (error) {
|
||||
// prepareSystemSshAgent can throw after acquire (e.g. IdentitiesOnly without
|
||||
// a readable .pub selector); release owned agent + askpass before rethrowing.
|
||||
releaseAcquiredFidoResources();
|
||||
throw error;
|
||||
} finally {
|
||||
if (skTempCleanup) {
|
||||
// Any session that adopted the staging dir into the identity refcount
|
||||
// above transfers cleanup (owned and shared agents alike); the last
|
||||
// release removes the dir after ssh-add -d finishes. Anything left here
|
||||
// is an unused/redundant temp dir.
|
||||
fs.promises.rm(skTempCleanup, { recursive: true, force: true }).catch(() => {});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -2082,6 +2600,15 @@ module.exports = {
|
||||
SSH_KEY_PATTERN,
|
||||
orderSshIdentityNames,
|
||||
looksLikePrivateKey,
|
||||
looksLikeSkOpenSshMaterial,
|
||||
identityFilesLookLikeSk,
|
||||
isFidoSkAuthOptions,
|
||||
buildFidoAwareAgentPrepOptions,
|
||||
enhanceAuthOptionsForFido,
|
||||
shouldUseSoftwareCertificateAgent,
|
||||
resolveWebContentsFromSender,
|
||||
resolvePreparedAgentSocket,
|
||||
materializeSkPrivateKeyFile,
|
||||
isKeyEncrypted,
|
||||
findDefaultPrivateKey,
|
||||
findAllDefaultPrivateKeys,
|
||||
|
||||
@@ -0,0 +1,659 @@
|
||||
"use strict";
|
||||
|
||||
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 {
|
||||
isFidoSkAuthOptions,
|
||||
buildFidoAwareAgentPrepOptions,
|
||||
enhanceAuthOptionsForFido,
|
||||
shouldUseSoftwareCertificateAgent,
|
||||
resolvePreparedAgentSocket,
|
||||
looksLikeSkOpenSshMaterial,
|
||||
identityFilesLookLikeSk,
|
||||
materializeSkPrivateKeyFile,
|
||||
} = require("./sshAuthHelper.cjs");
|
||||
|
||||
const SK_SSH_ED25519 = "sk-ssh-ed25519@openssh.com";
|
||||
const SK_ECDSA_NISTP256 = "sk-ecdsa-sha2-nistp256@openssh.com";
|
||||
|
||||
const skPub =
|
||||
"sk-ssh-ed25519@openssh.com AAAAGnNrLXNzaC1lZDI1NTE5QG9wZW5zc2guY29tAAAAIAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAIAAAABHNzaDo= test";
|
||||
|
||||
/** Real OpenSSH shape: algorithm only exists after base64 decode. */
|
||||
function makeSkPrivatePem(algo) {
|
||||
const body = Buffer.from(`openssh-key-v1\0\0\0\0\0none\0\0\0\0\0\0\0\0\0\x01${algo}`).toString("base64");
|
||||
const pem = `-----BEGIN OPENSSH PRIVATE KEY-----\n${body}\n-----END OPENSSH PRIVATE KEY-----\n`;
|
||||
assert.equal(pem.includes("@openssh.com"), false, "PEM must not contain plain sk type");
|
||||
return pem;
|
||||
}
|
||||
|
||||
test("isFidoSkAuthOptions detects public and private sk material", () => {
|
||||
assert.equal(isFidoSkAuthOptions({ agentPublicKeys: [skPub] }), true);
|
||||
assert.equal(isFidoSkAuthOptions({
|
||||
privateKey: "-----BEGIN OPENSSH PRIVATE KEY-----\nsk-ssh-ed25519@openssh.com\n-----END OPENSSH PRIVATE KEY-----",
|
||||
}), true);
|
||||
assert.equal(isFidoSkAuthOptions({ privateKey: "soft-key", useSshAgent: true }), false);
|
||||
assert.equal(looksLikeSkOpenSshMaterial(skPub), true);
|
||||
});
|
||||
|
||||
test("looksLikeSkOpenSshMaterial detects base64-only sk private PEMs", () => {
|
||||
const pem = makeSkPrivatePem(SK_SSH_ED25519);
|
||||
assert.equal(looksLikeSkOpenSshMaterial(pem), true);
|
||||
assert.equal(isFidoSkAuthOptions({ privateKey: pem }), true);
|
||||
const soft = "-----BEGIN OPENSSH PRIVATE KEY-----\nAAAA\n-----END OPENSSH PRIVATE KEY-----";
|
||||
assert.equal(looksLikeSkOpenSshMaterial(soft), false);
|
||||
assert.equal(isFidoSkAuthOptions({ privateKey: soft }), false);
|
||||
});
|
||||
|
||||
test("materializeSkPrivateKeyFile writes base64-only sk PEM handles", async () => {
|
||||
const pem = makeSkPrivatePem(SK_ECDSA_NISTP256);
|
||||
const result = await materializeSkPrivateKeyFile(pem, {
|
||||
fs,
|
||||
os,
|
||||
path,
|
||||
tempDirBridge: { getTempDir: () => os.tmpdir() },
|
||||
});
|
||||
assert.ok(result?.keyPath, "expected materialized path for real SK PEM");
|
||||
const written = fs.readFileSync(result.keyPath, "utf8");
|
||||
assert.equal(written, pem);
|
||||
fs.rmSync(result.cleanupDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test("buildFidoAwareAgentPrepOptions forces agent + askpass for base64-only SK PEMs", () => {
|
||||
const sender = { id: 1, isDestroyed: () => false };
|
||||
const pem = makeSkPrivatePem(SK_SSH_ED25519);
|
||||
const prep = buildFidoAwareAgentPrepOptions({
|
||||
useSshAgent: false,
|
||||
privateKey: pem,
|
||||
}, sender);
|
||||
assert.equal(prep.useSshAgent, true);
|
||||
assert.equal(prep.useFidoAgent, true);
|
||||
assert.equal(prep.loadIdentityFilesIntoAgent, true);
|
||||
assert.equal(prep.addKeysToAgent, "yes");
|
||||
assert.equal(typeof prep.resolveWebContents, "function");
|
||||
assert.equal(prep.resolveWebContents(), sender);
|
||||
});
|
||||
|
||||
test("buildFidoAwareAgentPrepOptions leaves soft keys alone", () => {
|
||||
const prep = buildFidoAwareAgentPrepOptions({
|
||||
useSshAgent: false,
|
||||
privateKey: "-----BEGIN OPENSSH PRIVATE KEY-----\nAAAA\n-----END OPENSSH PRIVATE KEY-----",
|
||||
});
|
||||
assert.equal(prep.useSshAgent, false);
|
||||
assert.equal(prep.useFidoAgent, false);
|
||||
});
|
||||
|
||||
test("resolvePreparedAgentSocket prefers agent annotation", () => {
|
||||
assert.equal(
|
||||
resolvePreparedAgentSocket({ _netcattyAgentSocket: "/tmp/fido.sock" }),
|
||||
"/tmp/fido.sock",
|
||||
);
|
||||
assert.equal(resolvePreparedAgentSocket(null), null);
|
||||
});
|
||||
|
||||
test("identityFilesLookLikeSk peeks .pub and private handle for path-only SK keys", async () => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-sk-path-"));
|
||||
const keyPath = path.join(dir, "id_ed25519_sk");
|
||||
const pubPath = `${keyPath}.pub`;
|
||||
fs.writeFileSync(pubPath, skPub);
|
||||
fs.writeFileSync(keyPath, makeSkPrivatePem(SK_SSH_ED25519));
|
||||
try {
|
||||
assert.equal(await identityFilesLookLikeSk([keyPath]), true);
|
||||
assert.equal(await identityFilesLookLikeSk([pubPath]), true);
|
||||
assert.equal(await identityFilesLookLikeSk([path.join(dir, "missing")]), false);
|
||||
} finally {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("looksLikeSkOpenSshMaterial recognizes sk certificate public keys", () => {
|
||||
assert.equal(
|
||||
looksLikeSkOpenSshMaterial(
|
||||
"sk-ssh-ed25519-cert-v01@openssh.com AAAAGnNrLXNzaC1lZDI1NTE5LWNlcnQtdjAxQG9wZW5zc2guY29tAAAA user",
|
||||
),
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test("prepareSystemSshAgentForAuth does not probe IdentityFiles for password auth", async () => {
|
||||
const { prepareSystemSshAgentForAuth } = require("./sshAuthHelper.cjs");
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-sk-pw-"));
|
||||
const keyPath = path.join(dir, "id_ed25519_sk");
|
||||
fs.writeFileSync(`${keyPath}.pub`, skPub);
|
||||
try {
|
||||
const agent = await prepareSystemSshAgentForAuth({
|
||||
authMethod: "password",
|
||||
useSshAgent: false,
|
||||
identityFilePaths: [keyPath],
|
||||
}, "[test]");
|
||||
assert.equal(agent, null);
|
||||
} finally {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("enhanceAuthOptionsForFido forces agent for path-only IdentityFile SK keys", async () => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-sk-enhance-"));
|
||||
const keyPath = path.join(dir, "id_ed25519_sk");
|
||||
fs.writeFileSync(`${keyPath}.pub`, skPub);
|
||||
try {
|
||||
const prep = await enhanceAuthOptionsForFido({
|
||||
useSshAgent: false,
|
||||
identityFilePaths: [keyPath],
|
||||
});
|
||||
assert.equal(prep.useSshAgent, true);
|
||||
assert.equal(prep.useFidoAgent, true);
|
||||
assert.equal(prep.loadIdentityFilesIntoAgent, true);
|
||||
} finally {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("shouldUseSoftwareCertificateAgent is false for FIDO SK certificates", () => {
|
||||
assert.equal(
|
||||
shouldUseSoftwareCertificateAgent({ certificate: "sk-ssh-ed25519-cert-v01@openssh.com AAAA" }, true),
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
shouldUseSoftwareCertificateAgent({ certificate: "ssh-ed25519-cert-v01@openssh.com AAAA" }, false),
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test("materializeSkPrivateKeyFile stages companion certificate for ssh-add", async () => {
|
||||
const pem = makeSkPrivatePem(SK_SSH_ED25519);
|
||||
const cert = "sk-ssh-ed25519-cert-v01@openssh.com AAAAGnNrLXNzaC1lZDI1NTE5LWNlcnQtdjAxQG9wZW5zc2guY29tAAAA user";
|
||||
const result = await materializeSkPrivateKeyFile(pem, {
|
||||
fs,
|
||||
path,
|
||||
tempDirBridge: { getTempDir: () => os.tmpdir() },
|
||||
certificate: cert,
|
||||
});
|
||||
assert.ok(result?.keyPath);
|
||||
assert.equal(fs.readFileSync(`${result.keyPath}-cert.pub`, "utf8").trim(), cert);
|
||||
fs.rmSync(result.cleanupDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test("materializeSkPrivateKeyFile fails closed without managed temp dir", async () => {
|
||||
const pem = makeSkPrivatePem(SK_SSH_ED25519);
|
||||
await assert.rejects(
|
||||
() => materializeSkPrivateKeyFile(pem, {
|
||||
fs,
|
||||
path,
|
||||
tempDirBridge: {},
|
||||
}),
|
||||
(error) => error?.code === "ERR_FIDO_TEMP_DIR_UNAVAILABLE",
|
||||
);
|
||||
});
|
||||
|
||||
test("failed companion writes remove the partially staged SK handle", async () => {
|
||||
const root = fs.mkdtempSync(path.join(require("./tempDirBridge.cjs").getTempDir(), "fw-"));
|
||||
try {
|
||||
for (const failAt of [2, 3]) {
|
||||
let writes = 0;
|
||||
const injectedFs = { promises: { ...fs.promises, writeFile: async (...args) => {
|
||||
writes += 1;
|
||||
if (writes === failAt) throw Object.assign(new Error("fixture write denied"), { code: "EACCES" });
|
||||
return fs.promises.writeFile(...args);
|
||||
} } };
|
||||
await assert.rejects(materializeSkPrivateKeyFile(makeSkPrivatePem(SK_SSH_ED25519), {
|
||||
fs: injectedFs, publicKey: skPub, certificate: "fixture certificate",
|
||||
tempDirBridge: { getTempDir: () => root },
|
||||
}), { code: "EACCES" });
|
||||
assert.deepEqual(fs.readdirSync(root), [], "partial credential directories must not remain");
|
||||
}
|
||||
} finally { fs.rmSync(root, { recursive: true, force: true }); }
|
||||
});
|
||||
|
||||
test("failed staging rejects the selected-key login and releases the acquired agent", async () => {
|
||||
const manager = require("./fidoAgentManager.cjs");
|
||||
const askpass = require("./fidoAskpass.cjs");
|
||||
const system = require("./systemSshAgent.cjs");
|
||||
const saved = { acquire: manager.acquireFidoAgent, release: manager.releaseFidoAgent,
|
||||
lease: askpass.releaseFidoAskpassLease, prepare: system.prepareSystemSshAgent, write: fs.promises.writeFile };
|
||||
let prepared = false;
|
||||
let releases = 0;
|
||||
let leases = 0;
|
||||
manager.acquireFidoAgent = async () => ({ socketPath: "/tmp/staging-failure.sock", generation: 7,
|
||||
owned: true, askpassEnv: { NETCATTY_FIDO_ASKPASS_LEASE: "staging-failure" } });
|
||||
manager.releaseFidoAgent = () => { releases += 1; };
|
||||
askpass.releaseFidoAskpassLease = () => { leases += 1; };
|
||||
system.prepareSystemSshAgent = async () => { prepared = true; return {}; };
|
||||
fs.promises.writeFile = async (...args) => {
|
||||
if (path.basename(args[0]) === "id_sk") throw Object.assign(new Error("fixture write denied"), { code: "EACCES" });
|
||||
return saved.write(...args);
|
||||
};
|
||||
try {
|
||||
await assert.rejects(require("./sshAuthHelper.cjs").prepareSystemSshAgentForAuth({
|
||||
privateKey: makeSkPrivatePem(SK_SSH_ED25519),
|
||||
}), { code: "EACCES" });
|
||||
assert.equal(prepared, false, "staging failure must not offer other sessions' identities");
|
||||
assert.equal(releases, 1);
|
||||
assert.equal(leases, 1);
|
||||
} finally {
|
||||
manager.acquireFidoAgent = saved.acquire;
|
||||
manager.releaseFidoAgent = saved.release;
|
||||
askpass.releaseFidoAskpassLease = saved.lease;
|
||||
system.prepareSystemSshAgent = saved.prepare;
|
||||
fs.promises.writeFile = saved.write;
|
||||
}
|
||||
});
|
||||
|
||||
test("prepareSystemSshAgentForAuth releases FIDO resources when preparation throws", async () => {
|
||||
const fidoAgentManager = require("./fidoAgentManager.cjs");
|
||||
const fidoAskpass = require("./fidoAskpass.cjs");
|
||||
const originalAcquire = fidoAgentManager.acquireFidoAgent;
|
||||
const originalRelease = fidoAgentManager.releaseFidoAgent;
|
||||
const originalReleaseLease = fidoAskpass.releaseFidoAskpassLease;
|
||||
const leaseId = "test-lease-prep-fail";
|
||||
let releasedAgent = 0;
|
||||
let releasedLease = 0;
|
||||
|
||||
fidoAgentManager.acquireFidoAgent = async () => ({
|
||||
socketPath: "/tmp/netcatty-fake-fido.sock",
|
||||
askpassEnv: {
|
||||
SSH_ASKPASS: "/bin/true",
|
||||
NETCATTY_FIDO_ASKPASS_LEASE: leaseId,
|
||||
},
|
||||
owned: true,
|
||||
generation: 1,
|
||||
});
|
||||
fidoAgentManager.releaseFidoAgent = () => { releasedAgent += 1; };
|
||||
fidoAskpass.releaseFidoAskpassLease = (id) => {
|
||||
if (id === leaseId) releasedLease += 1;
|
||||
};
|
||||
|
||||
try {
|
||||
const { prepareSystemSshAgentForAuth } = require("./sshAuthHelper.cjs");
|
||||
await assert.rejects(
|
||||
() => prepareSystemSshAgentForAuth({
|
||||
useSshAgent: true,
|
||||
useFidoAgent: true,
|
||||
identitiesOnly: true,
|
||||
identityFilePaths: [],
|
||||
}, "[test]"),
|
||||
(error) => error?.code === "ERR_SSH_AGENT_IDENTITY_SELECTOR_UNAVAILABLE",
|
||||
);
|
||||
assert.equal(releasedAgent, 1);
|
||||
assert.equal(releasedLease, 1);
|
||||
} finally {
|
||||
fidoAgentManager.acquireFidoAgent = originalAcquire;
|
||||
fidoAgentManager.releaseFidoAgent = originalRelease;
|
||||
fidoAskpass.releaseFidoAskpassLease = originalReleaseLease;
|
||||
}
|
||||
});
|
||||
|
||||
test("owned FIDO agent reference-counts identities: ssh-add -d only on the last session release", async () => {
|
||||
const fidoAgentManager = require("./fidoAgentManager.cjs");
|
||||
const fidoAskpass = require("./fidoAskpass.cjs");
|
||||
const systemSshAgent = require("./systemSshAgent.cjs");
|
||||
const childProcess = require("node:child_process");
|
||||
const originalAcquire = fidoAgentManager.acquireFidoAgent;
|
||||
const originalRelease = fidoAgentManager.releaseFidoAgent;
|
||||
const originalReleaseLease = fidoAskpass.releaseFidoAskpassLease;
|
||||
const originalPrepare = systemSshAgent.prepareSystemSshAgent;
|
||||
const originalExecFile = childProcess.execFile;
|
||||
const skPub =
|
||||
"sk-ssh-ed25519@openssh.com AAAAGnNrLXNzaC1lZDI1NTE5QG9wZW5zc2guY29tAAAAIAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAIAAAABHNzaDo= test";
|
||||
const blob = systemSshAgent.publicKeyBlob(skPub);
|
||||
const pathA = "/tmp/netcatty-owned-sk-a";
|
||||
const pathB = "/tmp/netcatty-owned-sk-b";
|
||||
const deleted = [];
|
||||
let generation = 0;
|
||||
|
||||
// The owned agent is a Netcatty-managed singleton shared across concurrent
|
||||
// sessions; releaseFidoAgent only drops one refcount and keeps it alive.
|
||||
fidoAgentManager.acquireFidoAgent = async () => {
|
||||
generation += 1;
|
||||
return {
|
||||
socketPath: "/tmp/netcatty-owned-agent.sock",
|
||||
askpassEnv: {
|
||||
SSH_ASKPASS: "/bin/true",
|
||||
NETCATTY_FIDO_ASKPASS_LEASE: `lease-${generation}`,
|
||||
},
|
||||
owned: true,
|
||||
generation,
|
||||
};
|
||||
};
|
||||
fidoAgentManager.releaseFidoAgent = () => {};
|
||||
fidoAskpass.releaseFidoAskpassLease = () => {};
|
||||
let prepCount = 0;
|
||||
systemSshAgent.prepareSystemSshAgent = async () => {
|
||||
prepCount += 1;
|
||||
const identityPath = prepCount === 1 ? pathA : pathB;
|
||||
return {
|
||||
getIdentities: (cb) => cb(null, []),
|
||||
sign: (_k, _d, _o, cb) => cb(new Error("unused")),
|
||||
_netcattyNewlyLoadedIdentityPaths: [identityPath],
|
||||
_netcattySharedAgentIdentities: [{ key: blob, identityPath }],
|
||||
};
|
||||
};
|
||||
childProcess.execFile = (file, args, opts, cb) => {
|
||||
if (Array.isArray(args) && args[0] === "-d") {
|
||||
deleted.push(args[1]);
|
||||
if (typeof cb === "function") cb(null, "", "");
|
||||
return { kill() {} };
|
||||
}
|
||||
return originalExecFile(file, args, opts, cb);
|
||||
};
|
||||
|
||||
try {
|
||||
systemSshAgent.resetSharedAgentIdentityRefsForTests();
|
||||
const { prepareSystemSshAgentForAuth } = require("./sshAuthHelper.cjs");
|
||||
const agentA = await prepareSystemSshAgentForAuth({
|
||||
useSshAgent: true,
|
||||
useFidoAgent: true,
|
||||
agentPublicKeys: [skPub],
|
||||
loadIdentityFilesIntoAgent: true,
|
||||
identityFilePaths: [pathA],
|
||||
}, "[test-owned-a]");
|
||||
const agentB = await prepareSystemSshAgentForAuth({
|
||||
useSshAgent: true,
|
||||
useFidoAgent: true,
|
||||
agentPublicKeys: [skPub],
|
||||
loadIdentityFilesIntoAgent: true,
|
||||
identityFilePaths: [pathB],
|
||||
}, "[test-owned-b]");
|
||||
|
||||
// Closing session A must not leave A's identity advertised to session B.
|
||||
agentA._releaseNetcattyFidoAgent();
|
||||
await new Promise(setImmediate);
|
||||
assert.deepEqual(deleted, [], "first release must keep the identity for the surviving session");
|
||||
agentB._releaseNetcattyFidoAgent();
|
||||
await new Promise(setImmediate);
|
||||
assert.deepEqual(deleted, [pathA], "last release removes the identity from the owned agent");
|
||||
} finally {
|
||||
fidoAgentManager.acquireFidoAgent = originalAcquire;
|
||||
fidoAgentManager.releaseFidoAgent = originalRelease;
|
||||
fidoAskpass.releaseFidoAskpassLease = originalReleaseLease;
|
||||
systemSshAgent.prepareSystemSshAgent = originalPrepare;
|
||||
childProcess.execFile = originalExecFile;
|
||||
systemSshAgent.resetSharedAgentIdentityRefsForTests();
|
||||
}
|
||||
});
|
||||
|
||||
test("prepareSystemSshAgentForAuth removes newly loaded identities from a shared Windows agent on release", async () => {
|
||||
const fidoAgentManager = require("./fidoAgentManager.cjs");
|
||||
const fidoAskpass = require("./fidoAskpass.cjs");
|
||||
const systemSshAgent = require("./systemSshAgent.cjs");
|
||||
const childProcess = require("node:child_process");
|
||||
const originalAcquire = fidoAgentManager.acquireFidoAgent;
|
||||
const originalRelease = fidoAgentManager.releaseFidoAgent;
|
||||
const originalReleaseLease = fidoAskpass.releaseFidoAskpassLease;
|
||||
const originalPrepare = systemSshAgent.prepareSystemSshAgent;
|
||||
const originalExecFile = childProcess.execFile;
|
||||
const leaseId = "test-lease-shared-win";
|
||||
const identityPath = "/tmp/netcatty-shared-sk";
|
||||
const skPub =
|
||||
"sk-ssh-ed25519@openssh.com AAAAGnNrLXNzaC1lZDI1NTE5QG9wZW5zc2guY29tAAAAIAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAIAAAABHNzaDo= test";
|
||||
const deleted = [];
|
||||
let releasedAgent = 0;
|
||||
|
||||
fidoAgentManager.acquireFidoAgent = async () => ({
|
||||
socketPath: "\\\\.\\pipe\\openssh-ssh-agent",
|
||||
askpassEnv: {
|
||||
SSH_ASKPASS: "/bin/true",
|
||||
NETCATTY_FIDO_ASKPASS_LEASE: leaseId,
|
||||
},
|
||||
owned: false,
|
||||
generation: 7,
|
||||
});
|
||||
fidoAgentManager.releaseFidoAgent = (generation) => {
|
||||
assert.equal(generation, 7);
|
||||
releasedAgent += 1;
|
||||
};
|
||||
fidoAskpass.releaseFidoAskpassLease = () => {};
|
||||
systemSshAgent.prepareSystemSshAgent = async () => ({
|
||||
getIdentities: (cb) => cb(null, []),
|
||||
sign: (_k, _d, _o, cb) => cb(new Error("unused")),
|
||||
_netcattyNewlyLoadedIdentityPaths: [identityPath],
|
||||
});
|
||||
childProcess.execFile = (file, args, opts, cb) => {
|
||||
if (Array.isArray(args) && args[0] === "-d") {
|
||||
deleted.push(args[1]);
|
||||
if (typeof cb === "function") cb(null, "", "");
|
||||
return { kill() {} };
|
||||
}
|
||||
return originalExecFile(file, args, opts, cb);
|
||||
};
|
||||
|
||||
try {
|
||||
systemSshAgent.resetSharedAgentIdentityRefsForTests?.();
|
||||
const { prepareSystemSshAgentForAuth } = require("./sshAuthHelper.cjs");
|
||||
const agent = await prepareSystemSshAgentForAuth({
|
||||
useSshAgent: true,
|
||||
useFidoAgent: true,
|
||||
agentPublicKeys: [skPub],
|
||||
loadIdentityFilesIntoAgent: true,
|
||||
identityFilePaths: [identityPath],
|
||||
}, "[test]");
|
||||
assert.ok(agent);
|
||||
assert.equal(typeof agent._releaseNetcattyFidoAgent, "function");
|
||||
agent._releaseNetcattyFidoAgent();
|
||||
await new Promise(setImmediate);
|
||||
assert.deepEqual(deleted, [identityPath]);
|
||||
assert.equal(releasedAgent, 1);
|
||||
} finally {
|
||||
fidoAgentManager.acquireFidoAgent = originalAcquire;
|
||||
fidoAgentManager.releaseFidoAgent = originalRelease;
|
||||
fidoAskpass.releaseFidoAskpassLease = originalReleaseLease;
|
||||
systemSshAgent.prepareSystemSshAgent = originalPrepare;
|
||||
childProcess.execFile = originalExecFile;
|
||||
systemSshAgent.resetSharedAgentIdentityRefsForTests?.();
|
||||
}
|
||||
});
|
||||
|
||||
test("shared Windows agent defers ssh-add -d until the last identity user releases", async () => {
|
||||
const fidoAgentManager = require("./fidoAgentManager.cjs");
|
||||
const fidoAskpass = require("./fidoAskpass.cjs");
|
||||
const systemSshAgent = require("./systemSshAgent.cjs");
|
||||
const childProcess = require("node:child_process");
|
||||
const { publicKeyBlob } = systemSshAgent;
|
||||
const originalAcquire = fidoAgentManager.acquireFidoAgent;
|
||||
const originalRelease = fidoAgentManager.releaseFidoAgent;
|
||||
const originalReleaseLease = fidoAskpass.releaseFidoAskpassLease;
|
||||
const originalPrepare = systemSshAgent.prepareSystemSshAgent;
|
||||
const originalExecFile = childProcess.execFile;
|
||||
const skPub =
|
||||
"sk-ssh-ed25519@openssh.com AAAAGnNrLXNzaC1lZDI1NTE5QG9wZW5zc2guY29tAAAAIAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAIAAAABHNzaDo= test";
|
||||
const blob = publicKeyBlob(skPub);
|
||||
const pathA = "/tmp/netcatty-shared-sk-a";
|
||||
const pathB = "/tmp/netcatty-shared-sk-b";
|
||||
const deleted = [];
|
||||
let generation = 0;
|
||||
|
||||
fidoAgentManager.acquireFidoAgent = async () => {
|
||||
generation += 1;
|
||||
return {
|
||||
socketPath: "\\\\.\\pipe\\openssh-ssh-agent",
|
||||
askpassEnv: {
|
||||
SSH_ASKPASS: "/bin/true",
|
||||
NETCATTY_FIDO_ASKPASS_LEASE: `lease-${generation}`,
|
||||
},
|
||||
owned: false,
|
||||
generation,
|
||||
};
|
||||
};
|
||||
fidoAgentManager.releaseFidoAgent = () => {};
|
||||
fidoAskpass.releaseFidoAskpassLease = () => {};
|
||||
let prepCount = 0;
|
||||
systemSshAgent.prepareSystemSshAgent = async () => {
|
||||
prepCount += 1;
|
||||
const identityPath = prepCount === 1 ? pathA : pathB;
|
||||
return {
|
||||
getIdentities: (cb) => cb(null, []),
|
||||
sign: (_k, _d, _o, cb) => cb(new Error("unused")),
|
||||
_netcattyNewlyLoadedIdentityPaths: [identityPath],
|
||||
_netcattySharedAgentIdentities: [{ key: blob, identityPath }],
|
||||
};
|
||||
};
|
||||
childProcess.execFile = (file, args, opts, cb) => {
|
||||
if (Array.isArray(args) && args[0] === "-d") {
|
||||
deleted.push(args[1]);
|
||||
if (typeof cb === "function") cb(null, "", "");
|
||||
return { kill() {} };
|
||||
}
|
||||
return originalExecFile(file, args, opts, cb);
|
||||
};
|
||||
|
||||
try {
|
||||
systemSshAgent.resetSharedAgentIdentityRefsForTests();
|
||||
const { prepareSystemSshAgentForAuth } = require("./sshAuthHelper.cjs");
|
||||
const agentA = await prepareSystemSshAgentForAuth({
|
||||
useSshAgent: true,
|
||||
useFidoAgent: true,
|
||||
agentPublicKeys: [skPub],
|
||||
loadIdentityFilesIntoAgent: true,
|
||||
identityFilePaths: [pathA],
|
||||
}, "[test-a]");
|
||||
const agentB = await prepareSystemSshAgentForAuth({
|
||||
useSshAgent: true,
|
||||
useFidoAgent: true,
|
||||
agentPublicKeys: [skPub],
|
||||
loadIdentityFilesIntoAgent: true,
|
||||
identityFilePaths: [pathB],
|
||||
}, "[test-b]");
|
||||
|
||||
agentA._releaseNetcattyFidoAgent();
|
||||
await new Promise(setImmediate);
|
||||
assert.deepEqual(deleted, [], "first release must keep the shared identity loaded");
|
||||
agentB._releaseNetcattyFidoAgent();
|
||||
await new Promise(setImmediate);
|
||||
assert.deepEqual(deleted, [pathA], "last release removes the identity via the retained path");
|
||||
} finally {
|
||||
fidoAgentManager.acquireFidoAgent = originalAcquire;
|
||||
fidoAgentManager.releaseFidoAgent = originalRelease;
|
||||
fidoAskpass.releaseFidoAskpassLease = originalReleaseLease;
|
||||
systemSshAgent.prepareSystemSshAgent = originalPrepare;
|
||||
childProcess.execFile = originalExecFile;
|
||||
systemSshAgent.resetSharedAgentIdentityRefsForTests();
|
||||
}
|
||||
});
|
||||
|
||||
test("shared Windows agent keeps staging dir until ssh-add -d finishes", async () => {
|
||||
const fidoAgentManager = require("./fidoAgentManager.cjs");
|
||||
const fidoAskpass = require("./fidoAskpass.cjs");
|
||||
const systemSshAgent = require("./systemSshAgent.cjs");
|
||||
const childProcess = require("node:child_process");
|
||||
const originalAcquire = fidoAgentManager.acquireFidoAgent;
|
||||
const originalRelease = fidoAgentManager.releaseFidoAgent;
|
||||
const originalReleaseLease = fidoAskpass.releaseFidoAskpassLease;
|
||||
const originalPrepare = systemSshAgent.prepareSystemSshAgent;
|
||||
const originalRetain = systemSshAgent.retainSharedAgentIdentity;
|
||||
const originalExecFile = childProcess.execFile;
|
||||
const stagingDir = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-sk-staging-"));
|
||||
const identityPath = path.join(stagingDir, "id_sk");
|
||||
fs.writeFileSync(identityPath, makeSkPrivatePem(SK_SSH_ED25519), { mode: 0o600 });
|
||||
/** @type {null | ((err: Error|null, stdout: string, stderr: string) => void)} */
|
||||
let pendingDeleteCb = null;
|
||||
|
||||
fidoAgentManager.acquireFidoAgent = async () => ({
|
||||
socketPath: "\\\\.\\pipe\\openssh-ssh-agent",
|
||||
askpassEnv: {
|
||||
SSH_ASKPASS: "/bin/true",
|
||||
NETCATTY_FIDO_ASKPASS_LEASE: "test-lease-staging-cleanup",
|
||||
},
|
||||
owned: false,
|
||||
generation: 9,
|
||||
});
|
||||
fidoAgentManager.releaseFidoAgent = () => {};
|
||||
fidoAskpass.releaseFidoAskpassLease = () => {};
|
||||
// Prep is mocked (no real ssh-add); still attach staging cleanup the way
|
||||
// materializeSkPrivateKeyFile + retainSharedAgentIdentity would in production.
|
||||
systemSshAgent.retainSharedAgentIdentity = (key, idPath, cleanupDir, socketPath) => (
|
||||
originalRetain(key, idPath, cleanupDir || stagingDir, socketPath)
|
||||
);
|
||||
systemSshAgent.prepareSystemSshAgent = async () => ({
|
||||
getIdentities: (cb) => cb(null, []),
|
||||
sign: (_k, _d, _o, cb) => cb(new Error("unused")),
|
||||
_netcattyNewlyLoadedIdentityPaths: [identityPath],
|
||||
});
|
||||
childProcess.execFile = (file, args, opts, cb) => {
|
||||
if (Array.isArray(args) && args[0] === "-d") {
|
||||
pendingDeleteCb = typeof cb === "function" ? cb : null;
|
||||
return { kill() {} };
|
||||
}
|
||||
return originalExecFile(file, args, opts, cb);
|
||||
};
|
||||
|
||||
try {
|
||||
systemSshAgent.resetSharedAgentIdentityRefsForTests?.();
|
||||
const { prepareSystemSshAgentForAuth } = require("./sshAuthHelper.cjs");
|
||||
const agent = await prepareSystemSshAgentForAuth({
|
||||
useSshAgent: true,
|
||||
useFidoAgent: true,
|
||||
agentPublicKeys: [skPub],
|
||||
loadIdentityFilesIntoAgent: true,
|
||||
identityFilePaths: [identityPath],
|
||||
}, "[test-staging-cleanup]");
|
||||
assert.ok(agent);
|
||||
assert.equal(typeof agent._releaseNetcattyFidoAgent, "function");
|
||||
|
||||
agent._releaseNetcattyFidoAgent();
|
||||
await new Promise(setImmediate);
|
||||
assert.ok(pendingDeleteCb, "expected ssh-add -d to start");
|
||||
assert.equal(
|
||||
fs.existsSync(stagingDir),
|
||||
true,
|
||||
"staging must survive until ssh-add -d returns",
|
||||
);
|
||||
assert.equal(fs.existsSync(identityPath), true);
|
||||
pendingDeleteCb(null, "", "");
|
||||
for (let i = 0; i < 40 && fs.existsSync(stagingDir); i += 1) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
}
|
||||
assert.equal(
|
||||
fs.existsSync(stagingDir),
|
||||
false,
|
||||
"staging removed after ssh-add -d completes",
|
||||
);
|
||||
} finally {
|
||||
fidoAgentManager.acquireFidoAgent = originalAcquire;
|
||||
fidoAgentManager.releaseFidoAgent = originalRelease;
|
||||
fidoAskpass.releaseFidoAskpassLease = originalReleaseLease;
|
||||
systemSshAgent.prepareSystemSshAgent = originalPrepare;
|
||||
systemSshAgent.retainSharedAgentIdentity = originalRetain;
|
||||
childProcess.execFile = originalExecFile;
|
||||
systemSshAgent.resetSharedAgentIdentityRefsForTests?.();
|
||||
fs.rmSync(stagingDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("materializeSkPrivateKeyFile writes companion .pub from publicKey hint", async () => {
|
||||
const pem = makeSkPrivatePem(SK_SSH_ED25519);
|
||||
const result = await materializeSkPrivateKeyFile(pem, {
|
||||
fs,
|
||||
path,
|
||||
tempDirBridge: { getTempDir: () => os.tmpdir() },
|
||||
publicKey: skPub,
|
||||
});
|
||||
assert.ok(result?.keyPath);
|
||||
assert.equal(fs.readFileSync(`${result.keyPath}.pub`, "utf8").trim(), skPub.trim());
|
||||
fs.rmSync(result.cleanupDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
|
||||
test("FIDO IdentityFile probing expands OpenSSH tokens with the connection context", async () => {
|
||||
const tempDirBridge = require("./tempDirBridge.cjs");
|
||||
const dir = fs.mkdtempSync(path.join(tempDirBridge.getTempDir(), "sk-path-"));
|
||||
const context = { hostname: "server.example", port: 2222, username: "deploy" };
|
||||
const keyPath = path.join(dir, "id_%h_%p_%r_%%");
|
||||
fs.writeFileSync(path.join(dir, "id_server.example_2222_deploy_%.pub"), skPub);
|
||||
try {
|
||||
assert.equal(await identityFilesLookLikeSk([keyPath], context), true);
|
||||
assert.equal(await identityFilesLookLikeSk([keyPath], { ...context, username: "other" }), false);
|
||||
const prep = await enhanceAuthOptionsForFido({
|
||||
...context, identityFilePaths: [keyPath], useSshAgent: false,
|
||||
});
|
||||
assert.equal(prep.useFidoAgent, true);
|
||||
assert.equal(prep.useSshAgent, true);
|
||||
assert.equal((await enhanceAuthOptionsForFido({
|
||||
...context, identityFilePaths: [keyPath], authMethod: "password", useSshAgent: false,
|
||||
})).useSshAgent, false);
|
||||
} finally {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
@@ -15,6 +15,7 @@ const { Client: SSHClient, utils: sshUtils } = require("ssh2");
|
||||
const { NetcattyAgent } = require("./netcattyAgent.cjs");
|
||||
const keyboardInteractiveHandler = require("./keyboardInteractiveHandler.cjs");
|
||||
const passphraseHandler = require("./passphraseHandler.cjs");
|
||||
const fidoPromptHandler = require("./fidoPromptHandler.cjs");
|
||||
const hostKeyVerifier = require("./hostKeyVerifier.cjs");
|
||||
const { createProxySocket, runWhenProxyConnectionReady } = require("./proxyUtils.cjs");
|
||||
const { attachX11Forwarding } = require("./x11Forwarding.cjs");
|
||||
@@ -43,6 +44,8 @@ const {
|
||||
loadFirstIdentityFileForAuth,
|
||||
hasUserConfiguredKey,
|
||||
isPasswordProvided,
|
||||
looksLikeSkOpenSshMaterial,
|
||||
identityFilesLookLikeSk,
|
||||
PassphraseCancelledError,
|
||||
isPassphraseCancelledError,
|
||||
} = require("./sshAuthHelper.cjs");
|
||||
@@ -617,9 +620,38 @@ async function connectThroughChain(event, options, jumpHosts, targetHost, target
|
||||
const hasCertificate =
|
||||
typeof jump.certificate === "string" && jump.certificate.trim().length > 0;
|
||||
|
||||
const systemAuthAgent = hasCertificate
|
||||
let isJumpFidoSk = looksLikeSkOpenSshMaterial(jump.privateKey)
|
||||
|| (Array.isArray(jump.agentPublicKeys)
|
||||
&& jump.agentPublicKeys.some((key) => looksLikeSkOpenSshMaterial(key)))
|
||||
|| jump.useFidoAgent === true;
|
||||
if (!isJumpFidoSk && jump.authMethod !== "password"
|
||||
&& Array.isArray(jump.identityFilePaths)
|
||||
&& jump.identityFilePaths.length > 0) {
|
||||
try {
|
||||
isJumpFidoSk = await identityFilesLookLikeSk(jump.identityFilePaths);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
const forceJumpFidoAgent = isJumpFidoSk && jump.authMethod !== "password";
|
||||
|
||||
// FIDO SK + certificate still needs the agent for hardware signing.
|
||||
const systemAuthAgent = (hasCertificate && !forceJumpFidoAgent)
|
||||
? null
|
||||
: await prepareSystemSshAgentForAuth(jump, `[Chain] Hop ${i + 1}:`);
|
||||
: await prepareSystemSshAgentForAuth({
|
||||
...jump,
|
||||
useSshAgent: forceJumpFidoAgent ? true : jump.useSshAgent,
|
||||
useFidoAgent: forceJumpFidoAgent || jump.useFidoAgent === true,
|
||||
loadIdentityFilesIntoAgent: forceJumpFidoAgent || jump.loadIdentityFilesIntoAgent,
|
||||
addKeysToAgent: jump.addKeysToAgent || (forceJumpFidoAgent ? "yes" : jump.addKeysToAgent),
|
||||
resolveWebContents: () => {
|
||||
try {
|
||||
return sender && !sender.isDestroyed?.() ? sender : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
},
|
||||
}, `[Chain] Hop ${i + 1}:`);
|
||||
|
||||
const identityFile = !jump.privateKey && !systemAuthAgent
|
||||
? await loadFirstIdentityFileForAuth({
|
||||
@@ -648,7 +680,9 @@ async function connectThroughChain(event, options, jumpHosts, targetHost, target
|
||||
},
|
||||
})
|
||||
: null;
|
||||
const inlineKey = jump.privateKey && !systemAuthAgent
|
||||
const inlineKey = jump.privateKey
|
||||
&& !systemAuthAgent
|
||||
&& !looksLikeSkOpenSshMaterial(jump.privateKey)
|
||||
? await preparePrivateKeyForAuth({
|
||||
sender,
|
||||
privateKey: jump.privateKey,
|
||||
@@ -674,8 +708,9 @@ async function connectThroughChain(event, options, jumpHosts, targetHost, target
|
||||
let authAgent = null;
|
||||
if (systemAuthAgent) {
|
||||
connOpts.agent = systemAuthAgent;
|
||||
require("./attachFidoAgentRelease.cjs").attachFidoAgentRelease(conn, systemAuthAgent);
|
||||
}
|
||||
if (hasCertificate) {
|
||||
if (hasCertificate && !forceJumpFidoAgent) {
|
||||
authAgent = new NetcattyAgent({
|
||||
mode: "certificate",
|
||||
webContents: event.sender,
|
||||
@@ -687,7 +722,7 @@ async function connectThroughChain(event, options, jumpHosts, targetHost, target
|
||||
},
|
||||
});
|
||||
connOpts.agent = authAgent;
|
||||
} else if (effectivePrivateKey) {
|
||||
} else if (effectivePrivateKey && !forceJumpFidoAgent) {
|
||||
connOpts.privateKey = effectivePrivateKey;
|
||||
if (effectivePassphrase) {
|
||||
connOpts.passphrase = effectivePassphrase;
|
||||
@@ -1016,6 +1051,25 @@ async function generateKeyPair(event, options) {
|
||||
const { type, bits, comment } = options;
|
||||
|
||||
try {
|
||||
// FIDO2 / security-key types require OpenSSH + libfido2 hardware interaction.
|
||||
// Generate via ssh-keygen so the key handle is created on the token.
|
||||
if (type === "ED25519-SK" || type === "ECDSA-SK") {
|
||||
const { generateFidoSshKeyPair } = require("./fidoSshKeygen.cjs");
|
||||
return await generateFidoSshKeyPair({
|
||||
type,
|
||||
comment: comment || "netcatty-fido-key",
|
||||
resident: options?.resident === true,
|
||||
verifyRequired: options?.verifyRequired === true,
|
||||
resolveWebContents: () => {
|
||||
try {
|
||||
return event?.sender && !event.sender.isDestroyed?.() ? event.sender : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
let keyType;
|
||||
let keyBits = bits;
|
||||
|
||||
@@ -1553,6 +1607,12 @@ function registerHandlers(ipcMain, options = {}) {
|
||||
"netcatty:host-key:respond",
|
||||
hostKeyVerifier,
|
||||
);
|
||||
registerOwnedAuthResponseHandler(
|
||||
ipcMain,
|
||||
terminalWorkerManager,
|
||||
"netcatty:fido-prompt:respond",
|
||||
fidoPromptHandler,
|
||||
);
|
||||
ipcMain.on("netcatty:zmodem:overwrite-response", (event, payload) => {
|
||||
terminalWorkerManager.send("netcatty:zmodem:overwrite-response", payload, {
|
||||
webContentsId: event?.sender?.id,
|
||||
@@ -1576,6 +1636,8 @@ function registerHandlers(ipcMain, options = {}) {
|
||||
keyboardInteractiveHandler.registerHandler(ipcMain);
|
||||
// Register the passphrase response handler
|
||||
passphraseHandler.registerHandler(ipcMain);
|
||||
// FIDO2 PIN / touch presence prompts (ssh-sk-helper / ssh-add / ssh-keygen)
|
||||
fidoPromptHandler.registerHandler(ipcMain);
|
||||
// Register the SSH host key verification response handler
|
||||
hostKeyVerifier.registerHandler(ipcMain);
|
||||
}
|
||||
|
||||
@@ -91,6 +91,54 @@ test("worker IPC installs one owner teardown across all monitor handlers", async
|
||||
]);
|
||||
});
|
||||
|
||||
test("starting monitoring publishes latency even when SSH keepalives are disabled", async () => {
|
||||
pool.resetSshTransportRegistryForTests({ defaultIdleTtlMs: 0 });
|
||||
const { selectServerStatsFixtureOutput } = require("./sshBridge/serverStatsTestHelpers.cjs");
|
||||
const handlers = register();
|
||||
const e = event(93);
|
||||
const options = {
|
||||
hostname: "latency-fixture",
|
||||
username: "fixture",
|
||||
password: "fixture-only",
|
||||
keepaliveInterval: 0,
|
||||
verifyHostKeys: false,
|
||||
};
|
||||
const conn = new EventEmitter();
|
||||
conn._sock = { destroyed: false };
|
||||
conn.config = { keepaliveInterval: 0 };
|
||||
conn.end = () => {};
|
||||
conn.exec = (command, callback) => {
|
||||
const stream = new EventEmitter();
|
||||
stream.stderr = new EventEmitter();
|
||||
stream.write = () => true;
|
||||
callback(null, stream);
|
||||
setImmediate(() => {
|
||||
stream.emit("data", Buffer.from(selectServerStatsFixtureOutput(command,
|
||||
"CPURAW:1000 900|CORES:4|PERCORERAW:|MEMINFO:8000 4000 100 900 0 0|PROCS:|DISKS:|NET:")));
|
||||
stream.emit("close", 0);
|
||||
});
|
||||
};
|
||||
const transport = pool.createTransport({ conn, endpoint: pool.buildConnectionReuseEndpoint(options) });
|
||||
const holder = {};
|
||||
pool.borrowTransport(transport, { kind: pool.LEASE_KINDS.shell, holder });
|
||||
try {
|
||||
await handlers.get("netcatty:monitor:update")(e, [{ id: "latency", hosts: [{ id: "host", options }] }]);
|
||||
let snapshot;
|
||||
const deadline = Date.now() + 2000;
|
||||
do {
|
||||
await tick();
|
||||
snapshot = handlers.get("netcatty:monitor:snapshot")(e);
|
||||
} while (snapshot.hosts.host?.status !== "live" && Date.now() < deadline);
|
||||
assert.equal(snapshot.hosts.host?.status, "live");
|
||||
assert.equal(snapshot.hosts.host.stats.memTotal, 8000);
|
||||
assert.equal(snapshot.hosts.host.stats.latencyMs, null, "keepalive opt-out is preserved");
|
||||
assert.equal(typeof snapshot.hosts.host.stats.responseLatencyMs, "number");
|
||||
} finally {
|
||||
bridge.stopAllMonitoring();
|
||||
pool.returnTransport(holder);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
test("worker receiving handlers accept the actual IPC sender without WebContents lifecycle methods", async () => {
|
||||
const { createWorkerSender } = require("../terminalWorker/process.cjs");
|
||||
|
||||
@@ -18,9 +18,12 @@ function createExecCommandApi(ctx) {
|
||||
: payload.useSshAgent === true
|
||||
? undefined
|
||||
: await getAvailableAgentSocket();
|
||||
const systemAuthAgent = hasCertificate
|
||||
const { enhanceAuthOptionsForFido } = require("../sshAuthHelper.cjs");
|
||||
const prep = await enhanceAuthOptionsForFido(payload, sender);
|
||||
const isFido = prep.useFidoAgent === true;
|
||||
const systemAuthAgent = (hasCertificate && !isFido)
|
||||
? null
|
||||
: await prepareSystemSshAgentForAuth(payload, "[SSH Exec]");
|
||||
: await prepareSystemSshAgentForAuth(prep, "[SSH Exec]");
|
||||
const defaultKeys = enableKeyboardInteractive && !(systemAuthAgent && payload.identitiesOnly)
|
||||
? await findAllDefaultPrivateKeysFromHelper()
|
||||
: [];
|
||||
@@ -146,7 +149,8 @@ function createExecCommandApi(ctx) {
|
||||
const effectivePassphrase = inlineKey?.passphrase || identityFilePassphrase;
|
||||
if (systemAuthAgent) {
|
||||
connectOpts.agent = systemAuthAgent;
|
||||
} else if (hasCertificate) {
|
||||
require("../attachFidoAgentRelease.cjs").attachFidoAgentRelease(conn, systemAuthAgent);
|
||||
} else if (hasCertificate && !isFido) {
|
||||
authAgent = new NetcattyAgent({
|
||||
mode: "certificate",
|
||||
webContents: event.sender,
|
||||
@@ -158,7 +162,7 @@ function createExecCommandApi(ctx) {
|
||||
},
|
||||
});
|
||||
connectOpts.agent = authAgent;
|
||||
} else if (effectivePrivateKey) {
|
||||
} else if (effectivePrivateKey && !isFido) {
|
||||
connectOpts.privateKey = effectivePrivateKey;
|
||||
if (effectivePassphrase) {
|
||||
connectOpts.passphrase = effectivePassphrase;
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
"use strict";
|
||||
const { executeBoundedSshCommand } = require("../boundedSshExec.cjs");
|
||||
|
||||
const GPU_ASSIGNMENT = `gpustat=$(gpucsv=$(timeout -s KILL 2 nvidia-smi --query-gpu=utilization.gpu,memory.used,memory.total,name --format=csv,noheader,nounits 2>/dev/null) && printf '%s\\n' "$gpucsv" | awk -F', *' 'NF >= 4 {g++; if($1 ~ /^[0-9]+$/) {n++; u+=$1} if($2 ~ /^[0-9]+$/) {mu+=$2; uc++} if($3 ~ /^[0-9]+$/) {mt+=$3; tc++} if(name=="") name=$4} END{if(n>0) printf "%.0f %s %s %s", u/n, (uc==g ? sprintf("%.0f",mu) : "N/A"), (tc==g ? sprintf("%.0f",mt) : "N/A"), name}' 2>/dev/null || echo "")`;
|
||||
const EMPTY_GPU = {
|
||||
gpu: null,
|
||||
gpuName: null,
|
||||
gpuMemUsed: null,
|
||||
gpuMemTotal: null,
|
||||
};
|
||||
const states = new WeakMap();
|
||||
function stateFor(conn) {
|
||||
let state = states.get(conn);
|
||||
if (!state) {
|
||||
state = {
|
||||
stats: EMPTY_GPU,
|
||||
nextAt: 0,
|
||||
pending: null,
|
||||
controller: null,
|
||||
updatedAt: 0,
|
||||
disabled: false,
|
||||
};
|
||||
states.set(conn, state);
|
||||
}
|
||||
return state;
|
||||
}
|
||||
function updateGpuStats(state, next) {
|
||||
if (Object.keys(EMPTY_GPU).some((key) => state.stats[key] !== next[key])) {
|
||||
// Publication time is separate from CPU history and strictly advances even
|
||||
// when a fast GPU reply shares the CPU sample's millisecond.
|
||||
state.updatedAt = Math.max(Date.now() + 1, state.updatedAt + 1);
|
||||
}
|
||||
state.stats = next;
|
||||
}
|
||||
function monitorGpuUpdatedAt(conn) {
|
||||
return states.get(conn)?.updatedAt ?? 0;
|
||||
}
|
||||
function parseGpuStats(output) {
|
||||
const raw =
|
||||
String(output || "")
|
||||
.split("|")
|
||||
.find((part) => part.startsWith("GPU:"))
|
||||
?.slice(4)
|
||||
.trim()
|
||||
.split(/\s+/) ?? [];
|
||||
const utilization = parseInt(raw[0], 10);
|
||||
if (!Number.isFinite(utilization)) return EMPTY_GPU;
|
||||
const number = (value) =>
|
||||
Number.isFinite(parseInt(value, 10)) ? parseInt(value, 10) : null;
|
||||
return {
|
||||
gpu: Math.max(0, Math.min(100, utilization)),
|
||||
gpuName: raw.slice(3).join(" ") || null,
|
||||
gpuMemUsed: number(raw[1]),
|
||||
gpuMemTotal: number(raw[2]),
|
||||
};
|
||||
}
|
||||
function withCachedMonitorGpu(conn, result) {
|
||||
const state = states.get(conn);
|
||||
return state && result?.success
|
||||
? { ...result, stats: { ...result.stats, ...state.stats } }
|
||||
: result;
|
||||
}
|
||||
function cacheFullGpuStats(conn, stats) {
|
||||
const state = stateFor(conn);
|
||||
state.controller?.abort();
|
||||
updateGpuStats(
|
||||
state,
|
||||
Object.fromEntries(
|
||||
Object.keys(EMPTY_GPU).map((key) => [key, stats[key] ?? null]),
|
||||
),
|
||||
);
|
||||
state.nextAt = Date.now() + 10000;
|
||||
}
|
||||
/** Optional GPU work never joins the CPU/memory sampler or its history clock. */
|
||||
function startMonitorGpuProbe(conn, command, options = {}) {
|
||||
const state = stateFor(conn);
|
||||
if (state.disabled || state.pending || Date.now() < state.nextAt) return;
|
||||
const controller = new AbortController();
|
||||
state.controller = controller;
|
||||
state.nextAt = Date.now() + 30000;
|
||||
state.pending = Promise.resolve()
|
||||
.then(() =>
|
||||
executeBoundedSshCommand(conn, command, {
|
||||
...options,
|
||||
signal: controller.signal,
|
||||
invalidateTransportOnAbort: false,
|
||||
invalidateOnOpenTimeout: false,
|
||||
openingTimeoutMs: 2500,
|
||||
runTimeoutMs: 2500,
|
||||
maxOutputBytes: 65536,
|
||||
}),
|
||||
)
|
||||
.then(({ stdout }) => {
|
||||
if (controller.signal.aborted) return;
|
||||
updateGpuStats(state, parseGpuStats(stdout));
|
||||
state.nextAt = Date.now() + (state.stats.gpu == null ? 30000 : 10000);
|
||||
})
|
||||
.catch((error) => {
|
||||
// A timed-out channel open cannot be withdrawn from ssh2's request FIFO.
|
||||
// Keep this optional probe from disconnecting SSH or piling up requests.
|
||||
if (error?.code === "SSH_EXEC_OPEN_TIMEOUT") state.disabled = true;
|
||||
if (!controller.signal.aborted) updateGpuStats(state, EMPTY_GPU);
|
||||
})
|
||||
.finally(() => {
|
||||
state.pending = null;
|
||||
if (state.controller === controller) state.controller = null;
|
||||
});
|
||||
}
|
||||
function cancelMonitorGpuProbe(conn) {
|
||||
states.get(conn)?.controller?.abort();
|
||||
}
|
||||
module.exports = {
|
||||
GPU_ASSIGNMENT,
|
||||
monitorGpuUpdatedAt,
|
||||
parseGpuStats,
|
||||
withCachedMonitorGpu,
|
||||
cacheFullGpuStats,
|
||||
startMonitorGpuProbe,
|
||||
cancelMonitorGpuProbe,
|
||||
};
|
||||
@@ -0,0 +1,166 @@
|
||||
"use strict";
|
||||
const test = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const { EventEmitter } = require("node:events");
|
||||
const {
|
||||
startMonitorGpuProbe,
|
||||
withCachedMonitorGpu,
|
||||
cacheFullGpuStats,
|
||||
cancelMonitorGpuProbe,
|
||||
} = require("./monitorGpuStats.cjs");
|
||||
const tick = () => new Promise((resolve) => setImmediate(resolve));
|
||||
function stream() {
|
||||
const s = new EventEmitter();
|
||||
s.stderr = new EventEmitter();
|
||||
s.close = () => {};
|
||||
s.destroy = () => {};
|
||||
return s;
|
||||
}
|
||||
const sample = { success: true, stats: { cpu: 12, memTotal: 4096, gpu: null } };
|
||||
test("GPU work is coalesced, cached independently, and never changes CPU readings", async (t) => {
|
||||
let now = 100000,
|
||||
calls = 0,
|
||||
current;
|
||||
t.mock.method(Date, "now", () => now);
|
||||
const conn = {
|
||||
exec(_command, cb) {
|
||||
calls++;
|
||||
current = stream();
|
||||
cb(null, current);
|
||||
},
|
||||
};
|
||||
t.after(() => cancelMonitorGpuProbe(conn));
|
||||
startMonitorGpuProbe(conn, "gpu");
|
||||
startMonitorGpuProbe(conn, "gpu");
|
||||
await tick();
|
||||
assert.equal(calls, 1);
|
||||
assert.equal(withCachedMonitorGpu(conn, sample).stats.cpu, 12);
|
||||
assert.equal(withCachedMonitorGpu(conn, sample).stats.gpu, null);
|
||||
current.emit("data", Buffer.from("GPU:75 1024 8192 NVIDIA T4"));
|
||||
current.emit("close", 0);
|
||||
await tick();
|
||||
assert.equal(withCachedMonitorGpu(conn, sample).stats.gpu, 75);
|
||||
assert.equal(withCachedMonitorGpu(conn, sample).stats.gpuName, "NVIDIA T4");
|
||||
now += 9999;
|
||||
startMonitorGpuProbe(conn, "gpu");
|
||||
await tick();
|
||||
assert.equal(calls, 1);
|
||||
now += 1;
|
||||
startMonitorGpuProbe(conn, "gpu");
|
||||
await tick();
|
||||
assert.equal(calls, 2);
|
||||
current.emit("close", 0);
|
||||
await tick();
|
||||
assert.equal(withCachedMonitorGpu(conn, sample).stats.gpu, null);
|
||||
now += 29000;
|
||||
startMonitorGpuProbe(conn, "gpu");
|
||||
await tick();
|
||||
assert.equal(calls, 2, "missing GPU probes back off");
|
||||
});
|
||||
test("cancelling optional GPU work preserves a full reader cache and ignores late results", async (t) => {
|
||||
let current;
|
||||
const conn = {
|
||||
exec(_command, cb) {
|
||||
current = stream();
|
||||
cb(null, current);
|
||||
},
|
||||
};
|
||||
startMonitorGpuProbe(conn, "gpu");
|
||||
await tick();
|
||||
cacheFullGpuStats(conn, {
|
||||
gpu: 42,
|
||||
gpuName: "NVIDIA full",
|
||||
gpuMemUsed: 512,
|
||||
gpuMemTotal: 8192,
|
||||
});
|
||||
current.emit("data", Buffer.from("GPU:99 1 2 NVIDIA late"));
|
||||
current.emit("close", 0);
|
||||
await tick();
|
||||
assert.equal(withCachedMonitorGpu(conn, sample).stats.gpu, 42);
|
||||
assert.equal(withCachedMonitorGpu(conn, sample).stats.cpu, 12);
|
||||
cancelMonitorGpuProbe(conn);
|
||||
t.after(() => cancelMonitorGpuProbe(conn));
|
||||
});
|
||||
|
||||
test("monitor snapshots include cached GPU without adding history samples or changing sample time", async () => {
|
||||
const { sampleSharedStats, readSharedStats } = require("./sharedStats.cjs");
|
||||
const conn = {};
|
||||
await sampleSharedStats({ conn, _monitorStats: true }, async () => sample);
|
||||
const before = readSharedStats(conn);
|
||||
cacheFullGpuStats(conn, {
|
||||
gpu: 42,
|
||||
gpuName: "NVIDIA cached",
|
||||
gpuMemUsed: 1,
|
||||
gpuMemTotal: 2,
|
||||
});
|
||||
const after = readSharedStats(conn);
|
||||
assert.equal(after.result.stats.gpu, 42);
|
||||
assert.equal(after.result.stats.cpu, 12);
|
||||
assert.equal(after.at, before.at);
|
||||
assert.deepEqual(after.points, before.points);
|
||||
});
|
||||
test("stopping GPU work before channel-open closes the late channel without disconnecting SSH", async () => {
|
||||
let callback,
|
||||
closed = 0,
|
||||
disconnected = 0;
|
||||
const conn = {
|
||||
exec(_command, cb) {
|
||||
callback = cb;
|
||||
},
|
||||
end() {
|
||||
disconnected++;
|
||||
},
|
||||
};
|
||||
startMonitorGpuProbe(conn, "gpu");
|
||||
await tick();
|
||||
require("./sharedStats.cjs").cancelSharedStats(conn);
|
||||
const late = stream();
|
||||
late.close = () => closed++;
|
||||
callback(null, late);
|
||||
await tick();
|
||||
assert.ok(closed > 0);
|
||||
assert.equal(disconnected, 0);
|
||||
});
|
||||
|
||||
test("optional GPU channel-open timeout neither disconnects SSH nor stacks repeated open requests", async () => {
|
||||
let callback,
|
||||
fireTimer,
|
||||
calls = 0,
|
||||
disconnected = 0,
|
||||
closed = 0;
|
||||
const conn = {
|
||||
exec(_command, cb) {
|
||||
calls++;
|
||||
callback = cb;
|
||||
},
|
||||
end() {
|
||||
disconnected++;
|
||||
},
|
||||
destroy() {
|
||||
disconnected++;
|
||||
},
|
||||
};
|
||||
startMonitorGpuProbe(conn, "gpu", {
|
||||
setTimeoutFn: (fn) => {
|
||||
fireTimer = fn;
|
||||
return 1;
|
||||
},
|
||||
clearTimeoutFn: () => {},
|
||||
});
|
||||
await tick();
|
||||
fireTimer();
|
||||
await tick();
|
||||
assert.equal(
|
||||
disconnected,
|
||||
0,
|
||||
"optional channel timeout must leave the shared transport intact",
|
||||
);
|
||||
startMonitorGpuProbe(conn, "gpu");
|
||||
await tick();
|
||||
assert.equal(calls, 1, "one unresolved GPU open per transport at most");
|
||||
const late = stream();
|
||||
late.close = () => closed++;
|
||||
callback(null, late);
|
||||
await tick();
|
||||
assert.ok(closed > 0);
|
||||
});
|
||||
@@ -49,6 +49,10 @@ function createMoshStatsConnectionApi(ctx) {
|
||||
// when not wired in (e.g. older callers / some unit tests) the verifier
|
||||
// simply skips the system-known_hosts fallback.
|
||||
const isHostKeyTrustedBySystem = ctx.isHostKeyTrustedBySystem;
|
||||
// FIDO2 sk-* key handles parse as OpenSSH keys but can never sign locally —
|
||||
// signing stays on the hardware token via ssh-add / the FIDO agent. The
|
||||
// stats companion must not build a software certificate agent from them.
|
||||
const { looksLikeSkOpenSshMaterial } = require("../sshAuthHelper.cjs");
|
||||
with (ctx) {
|
||||
// Resolve a usable, non-interactive private key (+ passphrase) for the
|
||||
// companion connection. Returns null when the key is missing, encrypted
|
||||
@@ -213,14 +217,24 @@ function createMoshStatsConnectionApi(ctx) {
|
||||
const hasCertificate =
|
||||
typeof auth.certificate === "string" && auth.certificate.trim().length > 0;
|
||||
const allowLocalKeyFallbackWithAgent = auth.authMethod === "auto";
|
||||
const inlineKey = auth.useSshAgent && !allowLocalKeyFallbackWithAgent
|
||||
let inlineKey = auth.useSshAgent && !allowLocalKeyFallbackWithAgent
|
||||
? null
|
||||
: resolveNonInteractiveKey(auth.privateKey, auth.passphrase);
|
||||
// SK key handles parse non-interactively but cannot sign locally. A
|
||||
// software NetcattyAgent built around one always fails signing — and its
|
||||
// non-null presence would skip prepareSystemSshAgentForAuth() below, so
|
||||
// the companion would never use the hardware-backed agent that the
|
||||
// Mosh/ET session itself authenticated with. Route SK material to the
|
||||
// prepared agent instead.
|
||||
if (inlineKey && looksLikeSkOpenSshMaterial(inlineKey.privateKey)) {
|
||||
inlineKey = null;
|
||||
}
|
||||
const keys = inlineKey
|
||||
? [inlineKey]
|
||||
: auth.useSshAgent && !allowLocalKeyFallbackWithAgent
|
||||
? []
|
||||
: await resolveNonInteractiveIdentityFiles(auth.identityFilePaths, auth.passphrase);
|
||||
: (await resolveNonInteractiveIdentityFiles(auth.identityFilePaths, auth.passphrase))
|
||||
.filter((candidate) => !looksLikeSkOpenSshMaterial(candidate.privateKey));
|
||||
const key = keys[0] || null;
|
||||
|
||||
let agent = null;
|
||||
@@ -250,7 +264,19 @@ function createMoshStatsConnectionApi(ctx) {
|
||||
}
|
||||
|
||||
if (!agent && auth.useSshAgent && typeof prepareSystemSshAgentForAuth === "function") {
|
||||
connectOpts.agent = await prepareSystemSshAgentForAuth(auth, `[${label} Stats]`);
|
||||
const prepared = await prepareSystemSshAgentForAuth({
|
||||
...auth,
|
||||
resolveWebContents: () => auth.webContents && !auth.webContents.isDestroyed?.()
|
||||
? auth.webContents : null,
|
||||
}, `[${label} Stats]`);
|
||||
connectOpts.agent = prepared;
|
||||
if (typeof prepared?._releaseNetcattyFidoAgent === "function") {
|
||||
// Surface the release hook on connectOpts so establishStatsConnection
|
||||
// can drop the owned FIDO agent / askpass lease on close, error, or
|
||||
// sessionGone — this consumer never attaches the agent to a long-lived
|
||||
// SSHClient lifecycle helper.
|
||||
connectOpts._releaseNetcattyFidoAgent = prepared._releaseNetcattyFidoAgent;
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof auth.password === "string" && auth.password.length > 0) {
|
||||
@@ -400,15 +426,30 @@ function createMoshStatsConnectionApi(ctx) {
|
||||
...auth,
|
||||
webContents,
|
||||
}, opts.label);
|
||||
const releaseStatsFidoAgent = (() => {
|
||||
const releaseFn = connectOpts?._releaseNetcattyFidoAgent;
|
||||
if (typeof releaseFn !== "function") return () => {};
|
||||
let released = false;
|
||||
return () => {
|
||||
if (released) return;
|
||||
released = true;
|
||||
try { releaseFn(); } catch { /* ignore */ }
|
||||
};
|
||||
})();
|
||||
// Do not forward the internal release hook into ssh2 connect options.
|
||||
delete connectOpts._releaseNetcattyFidoAgent;
|
||||
|
||||
if (!hasAnyAuth) {
|
||||
// Nothing we can authenticate with non-interactively (e.g. the user
|
||||
// typed a password into the Mosh handshake PTY that we never stored).
|
||||
releaseStatsFidoAgent();
|
||||
session[opts.failedProp] = true;
|
||||
return null;
|
||||
}
|
||||
|
||||
// The session may have been closed while we were reading identity files.
|
||||
if (sessionGone(session, sessionId)) {
|
||||
releaseStatsFidoAgent();
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -449,6 +490,7 @@ function createMoshStatsConnectionApi(ctx) {
|
||||
// retry.
|
||||
const fail = (err, permanent) => {
|
||||
try { conn.end(); } catch { /* ignore */ }
|
||||
releaseStatsFidoAgent();
|
||||
if (permanent) session[opts.failedProp] = true;
|
||||
finish(null);
|
||||
};
|
||||
@@ -457,6 +499,7 @@ function createMoshStatsConnectionApi(ctx) {
|
||||
// The session may have been closed while we were connecting.
|
||||
if (sessionGone(session, sessionId)) {
|
||||
try { conn.end(); } catch { /* ignore */ }
|
||||
releaseStatsFidoAgent();
|
||||
finish(null);
|
||||
return;
|
||||
}
|
||||
@@ -481,6 +524,7 @@ function createMoshStatsConnectionApi(ctx) {
|
||||
|
||||
conn.on("close", () => {
|
||||
if (session[opts.connProp] === conn) session[opts.connProp] = null;
|
||||
releaseStatsFidoAgent();
|
||||
// If the socket closed mid-handshake without ever emitting "ready"
|
||||
// or "error", settle the attempt here so the awaiting getServerStats
|
||||
// call (and the in-flight promise on opts.promiseProp) don't hang
|
||||
|
||||
@@ -320,6 +320,89 @@ test("automatic stats auth keeps local-key fallback with an explicit agent", asy
|
||||
]);
|
||||
});
|
||||
|
||||
test("sk-* key handle + certificate routes to the prepared hardware-backed agent, not a software cert agent", async () => {
|
||||
let agentConstructions = 0;
|
||||
const { api, sessions } = makeApi({
|
||||
NetcattyAgent: class {
|
||||
constructor() {
|
||||
agentConstructions += 1;
|
||||
}
|
||||
},
|
||||
prepareSystemSshAgentForAuth: async () => "/tmp/prepared-fido-agent.sock",
|
||||
});
|
||||
const session = {
|
||||
moshStatsAuth: {
|
||||
hostname: "h",
|
||||
username: "u",
|
||||
authMethod: "auto",
|
||||
useSshAgent: true,
|
||||
// SK private material parses non-interactively but can never sign
|
||||
// locally — building a software certificate agent around it fails auth
|
||||
// and blocks prepareSystemSshAgentForAuth below.
|
||||
privateKey: "sk-ssh-ed25519@openssh.com AAAA handle test",
|
||||
certificate: "sk-ssh-ed25519-cert-v01@openssh.com AAAA cert test",
|
||||
identityFilePaths: [],
|
||||
},
|
||||
};
|
||||
sessions.set("sid", session);
|
||||
|
||||
api.ensureMoshStatsConnection(session, "sid");
|
||||
await tick();
|
||||
await tick();
|
||||
|
||||
assert.equal(agentConstructions, 0, "software certificate agent must not be built from an SK handle");
|
||||
assert.equal(FakeSSHClient.instances[0].connectOpts.agent, "/tmp/prepared-fido-agent.sock");
|
||||
});
|
||||
|
||||
test("path-backed sk-* key handles are filtered out of the companion's identity files too", async () => {
|
||||
let agentConstructions = 0;
|
||||
const { api, sessions } = makeApi({
|
||||
NetcattyAgent: class {
|
||||
constructor() {
|
||||
agentConstructions += 1;
|
||||
}
|
||||
},
|
||||
readFileNoFollow: async () => "sk-ecdsa-sha2-nistp256@openssh.com AAAA handle test",
|
||||
prepareSystemSshAgentForAuth: async () => "/tmp/prepared-fido-agent.sock",
|
||||
});
|
||||
const session = {
|
||||
moshStatsAuth: {
|
||||
hostname: "h",
|
||||
username: "u",
|
||||
authMethod: "auto",
|
||||
useSshAgent: true,
|
||||
certificate: "sk-ecdsa-sha2-nistp256-cert-v01@openssh.com AAAA cert test",
|
||||
identityFilePaths: ["~/.ssh/id_sk"],
|
||||
},
|
||||
};
|
||||
sessions.set("sid", session);
|
||||
|
||||
api.ensureMoshStatsConnection(session, "sid");
|
||||
await tick();
|
||||
await tick();
|
||||
|
||||
assert.equal(agentConstructions, 0, "software certificate agent must not be built from a path-backed SK handle");
|
||||
assert.equal(FakeSSHClient.instances[0].connectOpts.agent, "/tmp/prepared-fido-agent.sock");
|
||||
});
|
||||
|
||||
test("FIDO stats preparation resolves only its requesting live window", async () => {
|
||||
let prepared;
|
||||
let destroyed = false;
|
||||
const sender = { id: 2823, isDestroyed: () => destroyed };
|
||||
const { api, sessions } = makeApi({ prepareSystemSshAgentForAuth: async auth => {
|
||||
prepared = auth;
|
||||
return "/tmp/fido-stats.sock";
|
||||
} });
|
||||
const session = { moshStatsAuth: { hostname: "h", username: "u", useSshAgent: true } };
|
||||
sessions.set("sid", session);
|
||||
api.ensureMoshStatsConnection(session, "sid", sender);
|
||||
await tick();
|
||||
assert.equal(prepared.resolveWebContents(), sender);
|
||||
destroyed = true;
|
||||
assert.equal(prepared.resolveWebContents(), null);
|
||||
FakeSSHClient.instances[0].emit("close");
|
||||
});
|
||||
|
||||
test("falls back to ssh-agent when a socket is available and no inline creds", async () => {
|
||||
// The system ssh used by the Mosh handshake authenticates via the local
|
||||
// agent by default, so the companion should too — regardless of the
|
||||
@@ -686,6 +769,70 @@ test("a connection that becomes ready after the session closed is discarded", as
|
||||
assert.equal(session.moshStatsConn, undefined);
|
||||
});
|
||||
|
||||
test("releases FIDO agent resources when the stats companion closes", async () => {
|
||||
let releaseCount = 0;
|
||||
const { api, sessions } = makeApi({
|
||||
prepareSystemSshAgentForAuth: async () => ({
|
||||
_releaseNetcattyFidoAgent: () => { releaseCount += 1; },
|
||||
}),
|
||||
});
|
||||
const session = {
|
||||
moshStatsAuth: {
|
||||
hostname: "h",
|
||||
username: "u",
|
||||
useSshAgent: true,
|
||||
},
|
||||
};
|
||||
sessions.set("sid", session);
|
||||
|
||||
const pending = api.ensureMoshStatsConnection(session, "sid");
|
||||
await tick();
|
||||
await tick();
|
||||
const client = FakeSSHClient.instances[0];
|
||||
assert.ok(client.connectOpts.agent);
|
||||
assert.equal(typeof client.connectOpts._releaseNetcattyFidoAgent, "undefined");
|
||||
client.emitReady();
|
||||
assert.equal(await pending, client);
|
||||
assert.equal(releaseCount, 0);
|
||||
|
||||
client.emit("close");
|
||||
assert.equal(releaseCount, 1);
|
||||
// close/end fan-out must stay one-shot
|
||||
client.emit("close");
|
||||
assert.equal(releaseCount, 1);
|
||||
});
|
||||
|
||||
test("releases FIDO agent resources when stats connect is abandoned via sessionGone", async () => {
|
||||
let releaseCount = 0;
|
||||
let resolvePrepare;
|
||||
const prepareGate = new Promise((resolve) => { resolvePrepare = resolve; });
|
||||
const { api, sessions } = makeApi({
|
||||
prepareSystemSshAgentForAuth: async () => {
|
||||
await prepareGate;
|
||||
return {
|
||||
_releaseNetcattyFidoAgent: () => { releaseCount += 1; },
|
||||
};
|
||||
},
|
||||
});
|
||||
const session = {
|
||||
moshStatsAuth: {
|
||||
hostname: "h",
|
||||
username: "u",
|
||||
useSshAgent: true,
|
||||
},
|
||||
};
|
||||
sessions.set("sid", session);
|
||||
|
||||
const pending = api.ensureMoshStatsConnection(session, "sid");
|
||||
await tick();
|
||||
session.closed = true;
|
||||
sessions.delete("sid");
|
||||
resolvePrepare();
|
||||
assert.equal(await pending, null);
|
||||
assert.equal(releaseCount, 1);
|
||||
assert.equal(FakeSSHClient.instances.length, 0);
|
||||
});
|
||||
|
||||
test("honors host algorithm settings via buildAlgorithms", async () => {
|
||||
const calls = [];
|
||||
const { api, sessions } = makeApi({
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
/* eslint-disable no-undef */
|
||||
const { sampleSharedStats } = require("./sharedStats.cjs");
|
||||
const { GPU_ASSIGNMENT, parseGpuStats, withCachedMonitorGpu, cacheFullGpuStats, startMonitorGpuProbe } = require("./monitorGpuStats.cjs");
|
||||
const { executeBoundedSshCommand } = require("../boundedSshExec.cjs");
|
||||
const { listInteractiveShellPids } = require("../sshInteractiveShells.cjs");
|
||||
function extraExecUnsupportedError(session) {
|
||||
@@ -1066,7 +1067,18 @@ function createSessionOpsApi(ctx) {
|
||||
}
|
||||
|
||||
async function getStatsForContext(session, event, sessionId) {
|
||||
return sampleSharedStats(session, (context) => collectServerStats(context, event, sessionId));
|
||||
const result = await sampleSharedStats(session, (context) => collectServerStats(context, event, sessionId));
|
||||
const conn = session.conn || session.moshStatsConn || session.etStatsConn;
|
||||
if (!conn || !result.success || !result.stats) return result;
|
||||
if (!session._monitorStats) {
|
||||
cacheFullGpuStats(conn, result.stats);
|
||||
return result;
|
||||
}
|
||||
if (!session.singleChannelSsh) {
|
||||
const command = withPosixRemoteWatchdog(`if [ "$(uname -s 2>/dev/null)" = "Linux" ]; then ${GPU_ASSIGNMENT}; echo "GPU:$gpustat"; fi`, 2500);
|
||||
startMonitorGpuProbe(conn, command, {setTimeoutFn:setTimeout,clearTimeoutFn:clearTimeout});
|
||||
}
|
||||
return withCachedMonitorGpu(conn, result);
|
||||
}
|
||||
|
||||
async function collectServerStats(session, event, sessionId) {
|
||||
@@ -1199,7 +1211,7 @@ function createSessionOpsApi(ctx) {
|
||||
`uptime=$(awk -v n="$nowsec" -v b="$bootsec" 'BEGIN{if(n>0&&b>0)printf "%.0f",n-b}')`,
|
||||
`loadavg=$(sysctl -n vm.loadavg 2>/dev/null | tr -d '{}' | awk '{print $1" "$2" "$3"}')`,
|
||||
`echo "CPU:$cpupct|CORES:$cores|MEMINFO:$memtotal $memfree 0 $memcached $swaptotal $swapfree|PROCS:$procs|DISKS:$disks|NET:$net|HOST:$hostname_value|OS:$osname|KERNEL:$kernel|UPTIME:$uptime|LOAD:$loadavg"`,
|
||||
].filter(command => !session._monitorStats || (!command.startsWith('procs=') && !command.startsWith('gpustat='))).join('; ');
|
||||
].filter(command => !session._monitorStats || !command.startsWith('procs=')).join('; ');
|
||||
|
||||
// Prefer df's filesystem type column so FUSE mounts can be identified
|
||||
// even when their source name looks like an ordinary remote path.
|
||||
@@ -1302,7 +1314,7 @@ function createSessionOpsApi(ctx) {
|
||||
// Bound GPU queries independently so a stuck driver cannot stop CPU/memory
|
||||
// polling. Hosts without timeout also omit this optional metric.
|
||||
// macOS is skipped: powermetrics needs root, which stats must not.
|
||||
`gpustat=$(gpucsv=$(timeout -s KILL 2 nvidia-smi --query-gpu=utilization.gpu,memory.used,memory.total,name --format=csv,noheader,nounits 2>/dev/null) && printf '%s\\n' "$gpucsv" | awk -F', *' 'NF >= 4 {g++; if($1 ~ /^[0-9]+$/) {n++; u+=$1} if($2 ~ /^[0-9]+$/) {mu+=$2; uc++} if($3 ~ /^[0-9]+$/) {mt+=$3; tc++} if(name=="") name=$4} END{if(n>0) printf "%.0f %s %s %s", u/n, (uc==g ? sprintf("%.0f",mu) : "N/A"), (tc==g ? sprintf("%.0f",mt) : "N/A"), name}' 2>/dev/null || echo "")`,
|
||||
GPU_ASSIGNMENT,
|
||||
// Output all stats (using CPURAW and PERCORERAW instead of CPU and PERCORE)
|
||||
`echo "CPURAW:$cpuraw|CORES:$cores|PERCORERAW:$percoreraw|MEMINFO:$meminfo|PROCS:$procs|NET:$net|GPU:$gpustat|HOST:$hostname_value|OS:$osname|KERNEL:$kernel|UPTIME:$uptime|LOAD:$loadavg"`
|
||||
].filter(command => !session._monitorStats || (!command.startsWith('procs=') && !command.startsWith('gpustat='))).join('; ');
|
||||
@@ -1364,6 +1376,12 @@ function createSessionOpsApi(ctx) {
|
||||
const pingLatencyPromise = sshStatsConn && !pingDisabled && typeof measureSshPingLatency === 'function'
|
||||
? Promise.resolve(measureSshPingLatency(sshStatsConn)).catch(() => null)
|
||||
: Promise.resolve(null);
|
||||
// Observe the exec acknowledgement already needed for this sample. This
|
||||
// remains available when global-request keepalives are disabled, without
|
||||
// sending another ping or opening an additional channel/connection.
|
||||
// Keep it separate from the transport RTT: channel setup also includes
|
||||
// remote processing, and shared terminal readers must retain that distinction.
|
||||
let responseLatencyMs = null;
|
||||
const formatStatsError = (error) => (
|
||||
error?.code === "SSH_EXEC_OPEN_TIMEOUT" || error?.code === "SSH_EXEC_RUN_TIMEOUT"
|
||||
? "Timeout getting server stats"
|
||||
@@ -1379,7 +1397,13 @@ function createSessionOpsApi(ctx) {
|
||||
};
|
||||
void (async () => {
|
||||
try {
|
||||
const { stdout } = await executeStatsCommand(statsCommand);
|
||||
const responseStartedAt = performance.now();
|
||||
const { stdout } = await executeStatsCommand(statsCommand, {
|
||||
...statsExecOptions,
|
||||
onStream: () => {
|
||||
if (sshStatsConn) responseLatencyMs = Math.max(0, Math.round(performance.now() - responseStartedAt));
|
||||
},
|
||||
});
|
||||
if (settled) return;
|
||||
let combinedStdout = String(stdout || '').trim();
|
||||
const primaryOutput = combinedStdout.replace(new RegExp(`^${latencyMarker}\\|?`), '');
|
||||
@@ -1555,27 +1579,7 @@ function createSessionOpsApi(ctx) {
|
||||
}
|
||||
}
|
||||
} else if (part.startsWith('GPU:')) {
|
||||
// Linux: "<avgUtil> <sumMemUsed> <sumMemTotal> <first GPU name>"
|
||||
const gpuStr = part.substring(4).trim();
|
||||
if (gpuStr && gpuStr !== '') {
|
||||
const gpuFields = gpuStr.split(/\s+/);
|
||||
const util = parseInt(gpuFields[0], 10);
|
||||
if (!isNaN(util)) {
|
||||
gpu = Math.min(100, Math.max(0, util));
|
||||
if (gpuFields.length >= 2) {
|
||||
const memUsed = parseInt(gpuFields[1], 10);
|
||||
if (!isNaN(memUsed)) gpuMemUsed = memUsed;
|
||||
}
|
||||
if (gpuFields.length >= 3) {
|
||||
const memTotal = parseInt(gpuFields[2], 10);
|
||||
if (!isNaN(memTotal)) gpuMemTotal = memTotal;
|
||||
}
|
||||
if (gpuFields.length >= 4) {
|
||||
const name = gpuFields.slice(3).join(' ').trim();
|
||||
if (name) gpuName = name;
|
||||
}
|
||||
}
|
||||
}
|
||||
({gpu,gpuName,gpuMemUsed,gpuMemTotal}=parseGpuStats(part));
|
||||
} else if (part.startsWith('HOST:')) {
|
||||
hostname = part.substring(5).trim();
|
||||
} else if (part.startsWith('OS:')) {
|
||||
@@ -1744,6 +1748,7 @@ function createSessionOpsApi(ctx) {
|
||||
netRxSpeed, // Total network receive speed (bytes/sec)
|
||||
netTxSpeed, // Total network transmit speed (bytes/sec)
|
||||
latencyMs, // SSH transport ping on the existing session (ms); never a second TCP dial (#3630)
|
||||
responseLatencyMs, // Existing stats exec acknowledgement (ms), including remote channel setup
|
||||
netInterfaces, // Per-interface network stats
|
||||
hostname,
|
||||
osName,
|
||||
|
||||
Loaded 100 of 160 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user