mirror of
https://github.com/binaricat/Netcatty.git
synced 2026-11-01 03:12:33 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
118c015bd5 | ||
|
|
88caf82e83 | ||
|
|
e3c45097c2 | ||
|
|
46de2023bd | ||
|
|
48f1a1cecf |
No files matched your search
@@ -113,7 +113,7 @@ jobs:
|
||||
- name: Exercise Windows pipes, PIN bridge, SK signatures and cancellation
|
||||
env:
|
||||
NETCATTY_FIDO_WINDOWS: "1"
|
||||
run: node --test scripts/fido-windows.live.test.cjs
|
||||
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
|
||||
|
||||
@@ -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 />
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
import { useMonitorRuntime } from '../state/useMonitorRuntime';
|
||||
|
||||
/** Keep monitoring state and subscriptions outside the app coordinator. */
|
||||
export function MonitorRuntimeHost() {
|
||||
useMonitorRuntime();
|
||||
return null;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
});
|
||||
@@ -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',
|
||||
|
||||
@@ -439,6 +439,12 @@ export const enTerminalMessages: Messages = {
|
||||
'cloudSync.history.download': 'Download',
|
||||
'cloudSync.history.resolved': 'Resolved',
|
||||
'cloudSync.history.error': 'Error',
|
||||
'cloudSync.localBackups.clearBusy': 'Finish restoring or recovering the vault before clearing backups.',
|
||||
'cloudSync.localBackups.clear': 'Clear all backups',
|
||||
'cloudSync.localBackups.clearConfirmTitle': 'Clear local backup history?',
|
||||
'cloudSync.localBackups.clearConfirmDesc': 'Permanently delete all local vault backups on this device. Your current hosts, saved credentials and cloud copies will remain. This does not prepare the device for handover. New backups will still be created during normal use.',
|
||||
'cloudSync.localBackups.clearSuccess': 'Local backup history cleared.',
|
||||
'cloudSync.localBackups.clearFailed': 'Could not clear all local backups. Please retry.',
|
||||
'cloudSync.localBackups.title': 'Local Backup History',
|
||||
'cloudSync.localBackups.desc': 'Netcatty keeps local restore points before app version changes and before vault restores.',
|
||||
'cloudSync.localBackups.retentionTitle': 'Backup Retention',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -438,6 +438,12 @@ export const esTerminalMessages: Messages = {
|
||||
'cloudSync.history.download': 'Descarga',
|
||||
'cloudSync.history.resolved': 'Resuelto',
|
||||
'cloudSync.history.error': 'Error',
|
||||
'cloudSync.localBackups.clearBusy': 'Finaliza la restauración o recuperación de la bóveda antes de eliminar las copias.',
|
||||
'cloudSync.localBackups.clear': 'Eliminar todas las copias',
|
||||
'cloudSync.localBackups.clearConfirmTitle': '¿Eliminar el historial de copias locales?',
|
||||
'cloudSync.localBackups.clearConfirmDesc': 'Elimina permanentemente todas las copias locales de la bóveda en este dispositivo. Los hosts actuales, las credenciales guardadas y las copias en la nube se conservan. Esto no prepara el dispositivo para entregarlo a otra persona. Se seguirán creando nuevas copias durante el uso normal.',
|
||||
'cloudSync.localBackups.clearSuccess': 'Historial de copias locales eliminado.',
|
||||
'cloudSync.localBackups.clearFailed': 'No se pudieron eliminar todas las copias locales. Inténtalo de nuevo.',
|
||||
'cloudSync.localBackups.title': 'Historial de copias de seguridad locales',
|
||||
'cloudSync.localBackups.desc': 'Netcatty conserva puntos de restauración locales antes de los cambios de versión de la app y antes de las restauraciones de la bóveda.',
|
||||
'cloudSync.localBackups.retentionTitle': 'Retención de copias de seguridad',
|
||||
|
||||
@@ -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': 'Выполняется',
|
||||
|
||||
@@ -457,6 +457,12 @@ export const ruTerminalMessages: Messages = {
|
||||
'cloudSync.history.download': 'Скачивание',
|
||||
'cloudSync.history.resolved': 'Разрешено',
|
||||
'cloudSync.history.error': 'Ошибка',
|
||||
'cloudSync.localBackups.clearBusy': 'Завершите восстановление хранилища перед удалением резервных копий.',
|
||||
'cloudSync.localBackups.clear': 'Удалить все копии',
|
||||
'cloudSync.localBackups.clearConfirmTitle': 'Удалить историю локальных копий?',
|
||||
'cloudSync.localBackups.clearConfirmDesc': 'Все локальные резервные копии хранилища на этом устройстве будут удалены безвозвратно. Текущие хосты, сохранённые учётные данные и облачные копии останутся. Это не очистка устройства для передачи другому человеку. При обычном использовании новые копии будут создаваться по-прежнему.',
|
||||
'cloudSync.localBackups.clearSuccess': 'История локальных копий удалена.',
|
||||
'cloudSync.localBackups.clearFailed': 'Не удалось удалить все локальные копии. Повторите попытку.',
|
||||
'cloudSync.localBackups.title': 'История локальных резервных копий',
|
||||
'cloudSync.localBackups.desc': 'Netcatty сохраняет локальные точки восстановления перед сменой версии приложения и перед восстановлением хранилища.',
|
||||
'cloudSync.localBackups.retentionTitle': 'Хранение резервных копий',
|
||||
|
||||
@@ -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`);
|
||||
}
|
||||
});
|
||||
@@ -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': '运行中',
|
||||
|
||||
@@ -842,6 +842,12 @@ export const zhCNVaultMessages: Messages = {
|
||||
'cloudSync.history.download': '下载',
|
||||
'cloudSync.history.resolved': '已解决',
|
||||
'cloudSync.history.error': '错误',
|
||||
'cloudSync.localBackups.clearBusy': '请先完成资料恢复或处理中断的恢复,再清空备份。',
|
||||
'cloudSync.localBackups.clear': '清空全部备份',
|
||||
'cloudSync.localBackups.clearConfirmTitle': '清空本地备份历史?',
|
||||
'cloudSync.localBackups.clearConfirmDesc': '将永久删除此设备上全部主机库的历史备份。当前主机、保存的凭据和云端副本仍会保留,这不代表设备已完成交接清理。以后正常使用时仍会生成新备份。',
|
||||
'cloudSync.localBackups.clearSuccess': '已清空本地备份历史。',
|
||||
'cloudSync.localBackups.clearFailed': '未能清空全部本地备份,请重试。',
|
||||
'cloudSync.localBackups.title': '本地备份历史',
|
||||
'cloudSync.localBackups.desc': 'Netcatty 会在版本变化前,以及恢复主机库前,自动留下一份本地恢复点。',
|
||||
'cloudSync.localBackups.retentionTitle': '备份保留数量',
|
||||
|
||||
@@ -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': '執行中',
|
||||
|
||||
@@ -842,6 +842,12 @@ export const zhTWVaultMessages: Messages = {
|
||||
'cloudSync.history.download': '下載',
|
||||
'cloudSync.history.resolved': '已解決',
|
||||
'cloudSync.history.error': '錯誤',
|
||||
'cloudSync.localBackups.clearBusy': '請先完成資料還原或處理中斷的還原,再清空備份。',
|
||||
'cloudSync.localBackups.clear': '清空全部備份',
|
||||
'cloudSync.localBackups.clearConfirmTitle': '清空本機備份歷史?',
|
||||
'cloudSync.localBackups.clearConfirmDesc': '將永久刪除此裝置上全部主機庫的歷史備份。目前的主機、儲存的憑證和雲端副本仍會保留,這不代表裝置已完成交接清理。日後正常使用時仍會產生新備份。',
|
||||
'cloudSync.localBackups.clearSuccess': '已清空本機備份歷史。',
|
||||
'cloudSync.localBackups.clearFailed': '未能清空全部本機備份,請重試。',
|
||||
'cloudSync.localBackups.title': '本機備份歷史',
|
||||
'cloudSync.localBackups.desc': 'Netcatty 會在版本變化前,以及恢復主機庫前,自動留下一份本機恢復點。',
|
||||
'cloudSync.localBackups.retentionTitle': '備份保留數量',
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import test, { type TestContext } from 'node:test';
|
||||
|
||||
import type { SyncPayload } from '../domain/sync.ts';
|
||||
|
||||
@@ -132,3 +132,89 @@ for (const localRestore of [false, true]) {
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function installBackupLocks(t: TestContext) {
|
||||
const previous = Object.getOwnPropertyDescriptor(globalThis, 'navigator');
|
||||
let readers = 0;
|
||||
let writer: Promise<void> | null = null;
|
||||
const locks = {
|
||||
async request(_name: string, options: { mode: string; ifAvailable?: boolean }, callback: (lock: object | null) => Promise<unknown>) {
|
||||
if (options.mode === 'shared') {
|
||||
if (writer) await writer;
|
||||
readers += 1;
|
||||
try { return await callback({}); } finally { readers -= 1; }
|
||||
}
|
||||
assert.equal(options.ifAvailable, true, 'cleanup must refuse rather than queue behind a restore');
|
||||
if (readers || writer) return callback(null);
|
||||
let release!: () => void;
|
||||
writer = new Promise<void>((resolve) => { release = resolve; });
|
||||
try { return await callback({}); } finally { writer = null; release(); }
|
||||
},
|
||||
};
|
||||
Object.defineProperty(globalThis, 'navigator', { configurable: true, value: { locks } });
|
||||
t.after(() => {
|
||||
if (previous) Object.defineProperty(globalThis, 'navigator', previous);
|
||||
else Reflect.deleteProperty(globalThis, 'navigator');
|
||||
});
|
||||
}
|
||||
|
||||
function installClearBridge(t: TestContext, clearVaultBackups: () => Promise<unknown>) {
|
||||
const previous = Object.getOwnPropertyDescriptor(globalThis, 'window');
|
||||
Object.defineProperty(globalThis, 'window', { configurable: true, value: { netcatty: { clearVaultBackups } } });
|
||||
t.after(() => {
|
||||
if (previous) Object.defineProperty(globalThis, 'window', previous);
|
||||
else Reflect.deleteProperty(globalThis, 'window');
|
||||
});
|
||||
}
|
||||
|
||||
test('cleanup refuses while another restore is applying, including nested restore helpers', async (t) => {
|
||||
installBackupLocks(t);
|
||||
const { clearLocalVaultBackups, withRestoreBarrier, LocalVaultBackupBusyError } = await import('./localVaultBackups.ts');
|
||||
let clears = 0;
|
||||
installClearBridge(t, async () => { clears += 1; });
|
||||
let finish!: () => void;
|
||||
let entered!: () => void;
|
||||
const active = new Promise<void>((resolve) => { entered = resolve; });
|
||||
const gate = new Promise<void>((resolve) => { finish = resolve; });
|
||||
const restoring = withRestoreBarrier(() => withRestoreBarrier(async () => { entered(); await gate; }));
|
||||
await active;
|
||||
try {
|
||||
await assert.rejects(clearLocalVaultBackups(), LocalVaultBackupBusyError);
|
||||
assert.equal(clears, 0);
|
||||
} finally {
|
||||
finish();
|
||||
await restoring;
|
||||
}
|
||||
await clearLocalVaultBackups();
|
||||
assert.equal(clears, 1);
|
||||
});
|
||||
|
||||
test('restore waits for an in-progress cleanup before reading or applying data', async (t) => {
|
||||
installBackupLocks(t);
|
||||
const { clearLocalVaultBackups, withRestoreBarrier } = await import('./localVaultBackups.ts');
|
||||
let finish!: () => void;
|
||||
let entered!: () => void;
|
||||
const active = new Promise<void>((resolve) => { entered = resolve; });
|
||||
const gate = new Promise<void>((resolve) => { finish = resolve; });
|
||||
installClearBridge(t, async () => { entered(); await gate; });
|
||||
const clearing = clearLocalVaultBackups();
|
||||
await active;
|
||||
let applied = false;
|
||||
const restoring = withRestoreBarrier(async () => { applied = true; });
|
||||
await Promise.resolve();
|
||||
assert.equal(applied, false);
|
||||
finish();
|
||||
await Promise.all([clearing, restoring]);
|
||||
assert.equal(applied, true);
|
||||
});
|
||||
|
||||
test('cleanup retains recovery backups after an interrupted protected apply', async (t) => {
|
||||
installBackupLocks(t);
|
||||
const { clearLocalVaultBackups, LocalVaultBackupBusyError } = await import('./localVaultBackups.ts');
|
||||
const { STORAGE_KEY_VAULT_APPLY_IN_PROGRESS } = await import('../infrastructure/config/storageKeys.ts');
|
||||
let clears = 0;
|
||||
installClearBridge(t, async () => { clears += 1; });
|
||||
localStorageValues.set(STORAGE_KEY_VAULT_APPLY_IN_PROGRESS, JSON.stringify({ startedAt: Date.now(), protectiveBackupId: 'recovery' }));
|
||||
await assert.rejects(clearLocalVaultBackups(), LocalVaultBackupBusyError);
|
||||
assert.equal(clears, 0);
|
||||
});
|
||||
@@ -10,6 +10,15 @@ import { getCloudSyncManager } from '../infrastructure/services/CloudSyncManager
|
||||
import { netcattyBridge } from '../infrastructure/services/netcattyBridge';
|
||||
import { hasMeaningfulSyncData } from './syncPayload';
|
||||
|
||||
const BACKUP_APPLY_LOCK = 'netcatty:vault-backup-apply';
|
||||
|
||||
export class LocalVaultBackupBusyError extends Error {
|
||||
constructor() {
|
||||
super('Finish restoring or recovering the vault before clearing backups.');
|
||||
this.name = 'LocalVaultBackupBusyError';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Snapshot the current sync data version (the integer that increments
|
||||
* on each successful cloud sync). Returns undefined when the value is
|
||||
@@ -86,6 +95,16 @@ export async function trimLocalVaultBackups(maxCount = getLocalVaultBackupMaxCou
|
||||
await bridge?.trimVaultBackups?.({ maxCount });
|
||||
}
|
||||
|
||||
export async function clearLocalVaultBackups(): Promise<void> {
|
||||
const bridge = netcattyBridge.get();
|
||||
const locks = typeof navigator === 'undefined' ? undefined : navigator.locks;
|
||||
if (!bridge?.clearVaultBackups || !locks) throw new Error('Local backup cleanup is unavailable.');
|
||||
await locks.request(BACKUP_APPLY_LOCK, { mode: 'exclusive', ifAvailable: true }, async (lock) => {
|
||||
if (!lock || readInterruptedVaultApply()) throw new LocalVaultBackupBusyError();
|
||||
await bridge.clearVaultBackups!();
|
||||
});
|
||||
}
|
||||
|
||||
export async function getLocalVaultBackupCapabilities(): Promise<{
|
||||
encryptionAvailable: boolean;
|
||||
}> {
|
||||
@@ -265,6 +284,15 @@ export async function withRestoreBarrier<T>(
|
||||
task: () => Promise<T>,
|
||||
holdMs: number = RESTORE_BARRIER_HOLD_MS,
|
||||
): Promise<T> {
|
||||
const locks = typeof navigator === 'undefined' ? undefined : navigator.locks;
|
||||
// Shared locks allow existing nested restore helpers. Cleanup never queues
|
||||
// an exclusive lock, so a nested helper cannot wait behind its own cleanup.
|
||||
return locks
|
||||
? locks.request(BACKUP_APPLY_LOCK, { mode: 'shared' }, () => runWithRestoreBarrier(task, holdMs))
|
||||
: runWithRestoreBarrier(task, holdMs);
|
||||
}
|
||||
|
||||
async function runWithRestoreBarrier<T>(task: () => Promise<T>, holdMs: number): Promise<T> {
|
||||
const writeDeadline = () => {
|
||||
try {
|
||||
localStorageAdapter.writeNumber(
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import {
|
||||
type LocalVaultBackupPreview,
|
||||
clearLocalVaultBackups,
|
||||
getLocalVaultBackupCapabilities,
|
||||
getLocalVaultBackupMaxCount,
|
||||
listLocalVaultBackups,
|
||||
@@ -20,13 +21,15 @@ export function useLocalVaultBackups() {
|
||||
// a destructive action that might later be disabled.
|
||||
const [encryptionAvailable, setEncryptionAvailable] = useState<boolean | null>(null);
|
||||
|
||||
const refreshSequence = useRef(0);
|
||||
const refreshBackups = useCallback(async () => {
|
||||
const sequence = ++refreshSequence.current;
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const next = await listLocalVaultBackups();
|
||||
setBackups(next);
|
||||
if (sequence === refreshSequence.current) setBackups(next);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
if (sequence === refreshSequence.current) setIsLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
@@ -76,6 +79,14 @@ export function useLocalVaultBackups() {
|
||||
return sanitized;
|
||||
}, [refreshBackups]);
|
||||
|
||||
const clearBackups = useCallback(async () => {
|
||||
try {
|
||||
await clearLocalVaultBackups();
|
||||
} finally {
|
||||
await refreshBackups();
|
||||
}
|
||||
}, [refreshBackups]);
|
||||
|
||||
const openBackupDirectory = useCallback(async () => {
|
||||
await openLocalVaultBackupDir();
|
||||
}, []);
|
||||
@@ -86,6 +97,7 @@ export function useLocalVaultBackups() {
|
||||
maxBackups,
|
||||
encryptionAvailable,
|
||||
refreshBackups,
|
||||
clearBackups,
|
||||
readBackup: readLocalVaultBackup,
|
||||
setMaxBackups: updateMaxBackups,
|
||||
openBackupDirectory,
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
@@ -1,22 +1,48 @@
|
||||
/** Serialized Web Locks fixture for tests with a simulated browser window. */
|
||||
/** Web Locks fixture for tests with a simulated browser window. */
|
||||
export function installBrowserLockFixture(): void {
|
||||
const tails = new Map<string, Promise<void>>();
|
||||
const states = new Map<string, {
|
||||
readers: number;
|
||||
writer: boolean;
|
||||
pending: Array<{ mode: LockMode; grant: () => void }>;
|
||||
}>();
|
||||
const locks = {
|
||||
async request<T>(name: string, task: () => Promise<T>): Promise<T> {
|
||||
const previous = tails.get(name) ?? Promise.resolve();
|
||||
let release!: () => void;
|
||||
const gate = new Promise<void>((resolve) => {
|
||||
release = resolve;
|
||||
});
|
||||
const tail = previous.then(() => gate);
|
||||
tails.set(name, tail);
|
||||
await previous;
|
||||
try {
|
||||
return await task();
|
||||
} finally {
|
||||
release();
|
||||
if (tails.get(name) === tail) tails.delete(name);
|
||||
async request<T>(
|
||||
name: string,
|
||||
optionsOrTask: LockOptions | ((lock: Pick<Lock, "name" | "mode"> | null) => T | Promise<T>),
|
||||
callback?: (lock: Pick<Lock, "name" | "mode"> | null) => T | Promise<T>,
|
||||
): Promise<T> {
|
||||
const options = typeof optionsOrTask === "function" ? {} : optionsOrTask;
|
||||
const task = typeof optionsOrTask === "function" ? optionsOrTask : callback!;
|
||||
const mode = options.mode ?? "exclusive";
|
||||
const state = states.get(name) ?? { readers: 0, writer: false, pending: [] };
|
||||
states.set(name, state);
|
||||
if (options.ifAvailable && (state.writer || state.pending.length || (mode === "exclusive" && state.readers))) {
|
||||
return task(null);
|
||||
}
|
||||
const drain = () => {
|
||||
while (!state.writer && state.pending.length) {
|
||||
const next = state.pending[0];
|
||||
if (next.mode === "exclusive" && state.readers) return;
|
||||
state.pending.shift();
|
||||
if (next.mode === "exclusive") state.writer = true;
|
||||
else state.readers += 1;
|
||||
next.grant();
|
||||
}
|
||||
};
|
||||
return new Promise<T>((resolve, reject) => {
|
||||
state.pending.push({ mode, grant: () => {
|
||||
void (async () => {
|
||||
try {
|
||||
return await task({ name, mode });
|
||||
} finally {
|
||||
if (mode === "exclusive") state.writer = false;
|
||||
else state.readers -= 1;
|
||||
drain();
|
||||
}
|
||||
})().then(resolve, reject);
|
||||
} });
|
||||
drain();
|
||||
});
|
||||
},
|
||||
};
|
||||
Object.defineProperty(globalThis, "navigator", {
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import React from 'react';
|
||||
import { I18nProvider } from '../application/i18n/I18nProvider';
|
||||
import { createDomRenderer, dispatchDomEvent, flushEffects, installDomEnvironment, runWithAct } from './test-support/renderReactDom';
|
||||
|
||||
test('backup cleanup confirms, preserves data on cancel, reports errors and refreshes after retry', async () => {
|
||||
const dom = installDomEnvironment();
|
||||
const globals = {
|
||||
localStorage: dom.window.localStorage,
|
||||
MutationObserver: dom.window.MutationObserver,
|
||||
NodeFilter: dom.window.NodeFilter,
|
||||
HTMLInputElement: dom.window.HTMLInputElement,
|
||||
};
|
||||
const previous = new Map(Object.keys(globals).map((key) => [key, Object.getOwnPropertyDescriptor(globalThis, key)]));
|
||||
for (const [key, value] of Object.entries(globals)) Object.defineProperty(globalThis, key, { configurable: true, value });
|
||||
Object.defineProperty(dom.window.navigator, 'locks', { configurable: true, value: {
|
||||
request: async (_name: string, _options: unknown, callback: (lock: object) => Promise<void>) => callback({}),
|
||||
} });
|
||||
const { LocalBackupsPanel } = await import('./cloud-sync/CloudSyncLocalBackupsPanel');
|
||||
const { toast } = await import('./ui/toast');
|
||||
const renderer = await createDomRenderer(dom.document);
|
||||
const notices: string[] = [];
|
||||
const success = toast.success;
|
||||
const error = toast.error;
|
||||
toast.success = (message) => { notices.push(`success:${message}`); };
|
||||
toast.error = (message) => { notices.push(`error:${message}`); };
|
||||
let calls = 0;
|
||||
let fail = true;
|
||||
let hasBackup = true;
|
||||
let finish: (() => void) | undefined;
|
||||
let finishProbe: ((result: { encryptionAvailable: boolean }) => void) | undefined;
|
||||
Object.defineProperty(dom.window, 'netcatty', { configurable: true, value: {
|
||||
// Cleanup must remain available even when encrypted backups cannot be read.
|
||||
getVaultBackupCapabilities: () => new Promise((resolve) => { finishProbe = resolve; }),
|
||||
listVaultBackups: async () => hasBackup ? [{
|
||||
id: 'fixture', createdAt: 1, reason: 'before_restore', fingerprint: 'fixture',
|
||||
preview: { hostCount: 1, keyCount: 0, snippetCount: 0, noteCount: 0, identityCount: 0, portForwardingRuleCount: 0 },
|
||||
}] : [],
|
||||
clearVaultBackups: async () => {
|
||||
calls += 1;
|
||||
if (fail) throw new Error('access denied');
|
||||
await new Promise<void>((resolve) => { finish = resolve; });
|
||||
hasBackup = false;
|
||||
return { deletedCount: 1 };
|
||||
},
|
||||
} });
|
||||
const click = async (node: Element) => {
|
||||
await dispatchDomEvent(node, new dom.window.MouseEvent('click', { bubbles: true }));
|
||||
await flushEffects();
|
||||
};
|
||||
const button = (text: string, scope: ParentNode = dom.document) => {
|
||||
const node = [...scope.querySelectorAll('button')].find((el) => el.textContent === text);
|
||||
assert.ok(node, `button ${text}`);
|
||||
return node as HTMLButtonElement;
|
||||
};
|
||||
try {
|
||||
await renderer.render(<I18nProvider locale="en"><LocalBackupsPanel onApplyPayload={() => { throw new Error('must not restore'); }} /></I18nProvider>);
|
||||
await flushEffects();
|
||||
assert.doesNotMatch(dom.document.body.textContent ?? '', /Master key required/);
|
||||
assert.equal([...dom.document.querySelectorAll('button')].some((el) => el.textContent === 'Restore'), false);
|
||||
await runWithAct(async () => { finishProbe?.({ encryptionAvailable: false }); });
|
||||
await flushEffects();
|
||||
assert.doesNotMatch(dom.document.body.textContent ?? '', /Master key required/);
|
||||
assert.equal([...dom.document.querySelectorAll('button')].some((el) => el.textContent === 'Restore'), false);
|
||||
await click(button('Clear all backups'));
|
||||
let dialog = dom.document.querySelector('[role="dialog"]');
|
||||
assert.ok(dialog);
|
||||
assert.match(dialog.textContent ?? '', /current hosts, saved credentials and cloud copies will remain/);
|
||||
await click(button('Cancel', dialog));
|
||||
assert.equal(calls, 0);
|
||||
assert.equal(hasBackup, true);
|
||||
await click(button('Clear all backups'));
|
||||
dialog = dom.document.querySelector('[role="dialog"]');
|
||||
assert.ok(dialog);
|
||||
await click(button('Clear all backups', dialog));
|
||||
assert.equal(calls, 1);
|
||||
assert.match(notices[0], /^error:/);
|
||||
assert.equal(hasBackup, true);
|
||||
dialog = dom.document.querySelector('[role="dialog"]');
|
||||
assert.ok(dialog);
|
||||
fail = false;
|
||||
await click(button('Clear all backups', dialog));
|
||||
assert.equal(calls, 2);
|
||||
assert.equal(button('Clear all backups', dialog).disabled, true);
|
||||
assert.equal(button('Cancel', dialog).disabled, true);
|
||||
assert.equal(notices.length, 1);
|
||||
await runWithAct(async () => { finish?.(); });
|
||||
await flushEffects();
|
||||
assert.equal(dom.document.querySelector('[role="dialog"]'), null);
|
||||
assert.match(dom.document.body.textContent ?? '', /No local backups yet/);
|
||||
assert.match(notices[1], /^success:/);
|
||||
} finally {
|
||||
toast.success = success;
|
||||
toast.error = error;
|
||||
await renderer.unmount();
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
for (const [key, descriptor] of previous) {
|
||||
if (descriptor) Object.defineProperty(globalThis, key, descriptor);
|
||||
else Reflect.deleteProperty(globalThis, key);
|
||||
}
|
||||
dom.cleanup();
|
||||
}
|
||||
});
|
||||
@@ -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';
|
||||
@@ -14,9 +14,11 @@ import {
|
||||
FolderOpen,
|
||||
Loader2,
|
||||
RefreshCw,
|
||||
Trash2,
|
||||
} from 'lucide-react';
|
||||
import { useLocalVaultBackups } from '../../application/state/useLocalVaultBackups';
|
||||
import {
|
||||
LocalVaultBackupBusyError,
|
||||
MAX_LOCAL_VAULT_BACKUP_MAX_COUNT,
|
||||
MIN_LOCAL_VAULT_BACKUP_MAX_COUNT,
|
||||
withRestoreBarrier,
|
||||
@@ -29,6 +31,7 @@ import { cn } from '../../lib/utils';
|
||||
import { Button } from '../ui/button';
|
||||
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '../ui/dialog';
|
||||
import { Input } from '../ui/input';
|
||||
import { ConfirmDialog } from '../ui/confirm-dialog';
|
||||
import { toast } from '../ui/toast';
|
||||
|
||||
// ============================================================================
|
||||
@@ -54,10 +57,13 @@ export const LocalBackupsPanel: React.FC<LocalBackupsPanelProps> = ({
|
||||
maxBackups,
|
||||
encryptionAvailable,
|
||||
refreshBackups,
|
||||
clearBackups,
|
||||
readBackup,
|
||||
setMaxBackups,
|
||||
openBackupDirectory,
|
||||
} = useLocalVaultBackups();
|
||||
const [showClearDialog, setShowClearDialog] = useState(false);
|
||||
const [isClearing, setIsClearing] = useState(false);
|
||||
const [maxBackupsInput, setMaxBackupsInput] = useState(String(maxBackups));
|
||||
const [isSavingMaxBackups, setIsSavingMaxBackups] = useState(false);
|
||||
const [restoringBackupId, setRestoringBackupId] = useState<string | null>(null);
|
||||
@@ -175,34 +181,34 @@ export const LocalBackupsPanel: React.FC<LocalBackupsPanelProps> = ({
|
||||
}
|
||||
};
|
||||
|
||||
const restoreAllowed = restoreDisabledReason === null;
|
||||
// While encryptionAvailable is still `null` we're mid-probe — render the
|
||||
// restore button as disabled so the user never sees a path they can't
|
||||
// actually take (I1 surface). Once resolved, `false` hides the panel body
|
||||
// via the unavailable banner below.
|
||||
const encryptionResolved = encryptionAvailable !== null;
|
||||
const encryptionUsable = encryptionAvailable === true;
|
||||
const performClear = async () => {
|
||||
if (isClearing) return;
|
||||
setIsClearing(true);
|
||||
try {
|
||||
await clearBackups();
|
||||
setShowClearDialog(false);
|
||||
toast.success(t('cloudSync.localBackups.clearSuccess'));
|
||||
} catch (error) {
|
||||
toast.error(t(error instanceof LocalVaultBackupBusyError
|
||||
? 'cloudSync.localBackups.clearBusy'
|
||||
: 'cloudSync.localBackups.clearFailed'), t('sync.toast.errorTitle'));
|
||||
} finally {
|
||||
setIsClearing(false);
|
||||
}
|
||||
};
|
||||
|
||||
// safeStorage probe finished and returned "not available" → disable the
|
||||
// panel entirely; the main process refuses to write in this state (I1).
|
||||
if (encryptionResolved && !encryptionUsable) {
|
||||
return (
|
||||
<div className="rounded-lg border border-amber-500/30 bg-amber-500/5 p-4 space-y-2">
|
||||
<div className="flex items-center gap-2 text-amber-600 dark:text-amber-400">
|
||||
<AlertTriangle size={16} />
|
||||
<span className="text-sm font-medium">
|
||||
{t('cloudSync.localBackups.unavailableTitle')}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{t('cloudSync.localBackups.unavailableDesc')}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
const restoreAllowed = restoreDisabledReason === null && encryptionAvailable === true;
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{encryptionAvailable === false && (
|
||||
<div className="rounded-lg border border-amber-500/30 bg-amber-500/5 p-4 space-y-2">
|
||||
<div className="text-sm font-medium text-amber-600 dark:text-amber-400">
|
||||
{t('cloudSync.localBackups.unavailableTitle')}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">{t('cloudSync.localBackups.unavailableDesc')}</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="rounded-lg border bg-card p-4">
|
||||
<div className="flex flex-col gap-4 md:flex-row md:items-start md:justify-between">
|
||||
<div className="max-w-lg">
|
||||
@@ -224,7 +230,7 @@ export const LocalBackupsPanel: React.FC<LocalBackupsPanelProps> = ({
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => void handleSaveMaxBackups()}
|
||||
disabled={isSavingMaxBackups}
|
||||
disabled={isSavingMaxBackups || isClearing || restoringBackupId !== null}
|
||||
className="gap-2"
|
||||
>
|
||||
{isSavingMaxBackups && <Loader2 size={14} className="animate-spin" />}
|
||||
@@ -235,7 +241,7 @@ export const LocalBackupsPanel: React.FC<LocalBackupsPanelProps> = ({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!restoreAllowed && (
|
||||
{restoreDisabledReason !== null && (
|
||||
<div className="rounded-lg border border-amber-500/30 bg-amber-500/5 p-3 text-xs text-muted-foreground">
|
||||
<div className="flex items-center gap-2 text-amber-600 dark:text-amber-400 mb-1">
|
||||
<AlertTriangle size={14} />
|
||||
@@ -248,19 +254,19 @@ export const LocalBackupsPanel: React.FC<LocalBackupsPanelProps> = ({
|
||||
)}
|
||||
|
||||
<div className="rounded-lg border bg-card p-4 space-y-4">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div>
|
||||
<div className="text-sm font-medium">{t('cloudSync.localBackups.title')}</div>
|
||||
<div className="text-xs text-muted-foreground mt-1">
|
||||
{t('cloudSync.localBackups.desc')}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => void refreshBackups()}
|
||||
disabled={isLoading}
|
||||
disabled={isLoading || isClearing}
|
||||
className="gap-1"
|
||||
>
|
||||
<RefreshCw size={14} className={cn(isLoading && 'animate-spin')} />
|
||||
@@ -275,6 +281,16 @@ export const LocalBackupsPanel: React.FC<LocalBackupsPanelProps> = ({
|
||||
<FolderOpen size={14} />
|
||||
{t('settings.system.openFolder')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setShowClearDialog(true)}
|
||||
disabled={isLoading || isClearing || isSavingMaxBackups || restoringBackupId !== null}
|
||||
className="gap-1 text-destructive hover:text-destructive"
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
{t('cloudSync.localBackups.clear')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -339,7 +355,7 @@ export const LocalBackupsPanel: React.FC<LocalBackupsPanelProps> = ({
|
||||
// clicks here would interleave destructive
|
||||
// writes and the second run's sentinel-clear
|
||||
// could mask a still-partial first apply.
|
||||
disabled={restoringBackupId !== null}
|
||||
disabled={restoringBackupId !== null || isClearing}
|
||||
className="gap-2"
|
||||
>
|
||||
{restoringBackupId === backup.id ? (
|
||||
@@ -356,6 +372,17 @@ export const LocalBackupsPanel: React.FC<LocalBackupsPanelProps> = ({
|
||||
)}
|
||||
</div>
|
||||
|
||||
<ConfirmDialog
|
||||
open={showClearDialog}
|
||||
title={t('cloudSync.localBackups.clearConfirmTitle')}
|
||||
message={t('cloudSync.localBackups.clearConfirmDesc')}
|
||||
confirmLabel={t('cloudSync.localBackups.clear')}
|
||||
busy={isClearing}
|
||||
destructive
|
||||
onOpenChange={(open) => { if (!isClearing) setShowClearDialog(open); }}
|
||||
onConfirm={() => void performClear()}
|
||||
/>
|
||||
|
||||
{/* Restore confirmation dialog (I2). Keeps the destructive action
|
||||
gated behind an explicit second click, mirroring the clear-local
|
||||
dialog elsewhere in this screen. */}
|
||||
@@ -416,7 +443,7 @@ export const LocalBackupsPanel: React.FC<LocalBackupsPanelProps> = ({
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setPendingRestoreBackup(null)}
|
||||
disabled={restoringBackupId !== null}
|
||||
disabled={restoringBackupId !== null || isClearing}
|
||||
>
|
||||
{t('cloudSync.localBackups.restoreConfirmCancel')}
|
||||
</Button>
|
||||
@@ -428,7 +455,7 @@ export const LocalBackupsPanel: React.FC<LocalBackupsPanelProps> = ({
|
||||
setPendingRestoreBackup(null);
|
||||
await performRestore(target.id);
|
||||
}}
|
||||
disabled={restoringBackupId !== null}
|
||||
disabled={restoringBackupId !== null || isClearing}
|
||||
className="gap-2"
|
||||
>
|
||||
{restoringBackupId !== null ? (
|
||||
|
||||
@@ -31,11 +31,27 @@ 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 exercises Windows named-pipe askpass IPC, enrollment and actual signatures
|
||||
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
|
||||
|
||||
@@ -117,10 +117,10 @@ function resolveSshAgentBinary(env = process.env, platform = process.platform) {
|
||||
}
|
||||
|
||||
function parseAgentStdout(stdout) {
|
||||
const sockMatch = /SSH_AUTH_SOCK=([^;\s]+)/.exec(stdout || "");
|
||||
const sockMatch = /SSH_AUTH_SOCK=([^;\r\n]+)/.exec(stdout || "");
|
||||
const pidMatch = /SSH_AGENT_PID=(\d+)/.exec(stdout || "");
|
||||
return {
|
||||
socketPath: sockMatch?.[1] || null,
|
||||
socketPath: sockMatch?.[1]?.trim().replace(/^(['"])(.*)\1$/, "$2") || null,
|
||||
agentPid: pidMatch ? Number(pidMatch[1]) : null,
|
||||
};
|
||||
}
|
||||
@@ -230,7 +230,9 @@ async function acquireFidoAgent(options = {}) {
|
||||
// Caller-specific askpass lease — never reuse the starter's resolver.
|
||||
return {
|
||||
socketPath: shared.socketPath,
|
||||
askpassEnv: buildFidoAskpassEnv({ resolveWebContents: options.resolveWebContents }),
|
||||
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,
|
||||
@@ -242,7 +244,9 @@ async function acquireFidoAgent(options = {}) {
|
||||
refCount += 1;
|
||||
return {
|
||||
socketPath: agentSocket,
|
||||
askpassEnv: buildFidoAskpassEnv({ resolveWebContents: options.resolveWebContents }),
|
||||
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,
|
||||
@@ -256,8 +260,10 @@ async function acquireFidoAgent(options = {}) {
|
||||
startingPromise = (async () => {
|
||||
const run = options.execFile || execFileAsync;
|
||||
const env = options.env || process.env;
|
||||
const askpassEnv = buildFidoAskpassEnv({ resolveWebContents: options.resolveWebContents });
|
||||
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);
|
||||
@@ -267,10 +273,11 @@ async function acquireFidoAgent(options = {}) {
|
||||
};
|
||||
|
||||
try {
|
||||
const sshAgent = resolveSshAgentBinary(env, platform);
|
||||
const sshAddPath = companionSshAddPath(sshAgent);
|
||||
|
||||
agentDir = path.join(getTempBase(), `netcatty-fido-agent-${randomUUID().slice(0, 8)}`);
|
||||
// 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,
|
||||
@@ -285,7 +292,7 @@ async function acquireFidoAgent(options = {}) {
|
||||
const sockCandidates = platform === "win32"
|
||||
? [
|
||||
`\\\\.\\pipe\\netcatty-fido-agent-${randomUUID().slice(0, 8)}`,
|
||||
path.join(agentDir, "agent.sock"),
|
||||
path.join(agentDir, "agent.sock").replace(/\\/g, "/"),
|
||||
]
|
||||
: [path.join(agentDir, "agent.sock")];
|
||||
|
||||
|
||||
@@ -20,6 +20,13 @@ test("parseAgentStdout extracts sock and pid", () => {
|
||||
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();
|
||||
|
||||
@@ -182,7 +182,7 @@ function resolveFidoAskpassSocketPath(baseDir, platform = process.platform) {
|
||||
if (platform === "win32") {
|
||||
return `\\\\.\\pipe\\netcatty-fido-askpass-${randomUUID().slice(0, 8)}`;
|
||||
}
|
||||
return path.join(baseDir, "askpass.sock");
|
||||
return path.posix.join(baseDir, "askpass.sock");
|
||||
}
|
||||
|
||||
function writeSecureFile(filePath, contents, mode = 0o700) {
|
||||
@@ -243,7 +243,7 @@ function resolveSharedAgentPromptSender() {
|
||||
|| liveSenderFromResolver(resolveWebContents);
|
||||
}
|
||||
|
||||
function handleAskpassClient(socket) {
|
||||
function handleAskpassClient(socket, platform = process.platform) {
|
||||
let buf = "";
|
||||
let handled = false;
|
||||
const controller = new AbortController();
|
||||
@@ -302,14 +302,28 @@ function handleAskpassClient(socket) {
|
||||
}
|
||||
const signing = signingQueue[0]?.leaseId === ownerLeaseId ? signingQueue[0] : null;
|
||||
try {
|
||||
const result = await fidoPromptHandler.requestFidoPrompt(sender, {
|
||||
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: controller.signal,
|
||||
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;
|
||||
@@ -319,14 +333,16 @@ function handleAskpassClient(socket) {
|
||||
// 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 && /user presence|touch|tap/i.test(prompt)
|
||||
&& signing?.leaseId === ownerLeaseId && !signing.controller.signal.aborted) {
|
||||
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,
|
||||
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`);
|
||||
@@ -360,6 +376,12 @@ function ensureFidoAskpass(options = {}) {
|
||||
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(
|
||||
@@ -368,14 +390,7 @@ function ensureFidoAskpass(options = {}) {
|
||||
0o700,
|
||||
);
|
||||
} else {
|
||||
askpassWrapperPath = path.join(base, "netcatty-fido-askpass.sh");
|
||||
const electronExec = JSON.stringify(process.execPath);
|
||||
const scriptExec = JSON.stringify(askpassScriptPath);
|
||||
writeSecureFile(
|
||||
askpassWrapperPath,
|
||||
`#!/bin/sh\nELECTRON_RUN_AS_NODE=1 exec ${electronExec} ${scriptExec} "$@"\n`,
|
||||
0o700,
|
||||
);
|
||||
askpassWrapperPath = posixWrapperPath;
|
||||
}
|
||||
|
||||
if (!isWindowsNamedPipePath(askpassSocketPath) && fs.existsSync(askpassSocketPath)) {
|
||||
@@ -402,9 +417,31 @@ function ensureFidoAskpass(options = {}) {
|
||||
};
|
||||
}
|
||||
|
||||
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 }} [options]
|
||||
* @param {{ resolveWebContents?: () => import("electron").WebContents|null, sshBinaryPath?: string, env?: NodeJS.ProcessEnv }} [options]
|
||||
*/
|
||||
function buildFidoAskpassEnv(options = {}) {
|
||||
const artifacts = ensureFidoAskpass(options);
|
||||
@@ -413,7 +450,7 @@ function buildFidoAskpassEnv(options = {}) {
|
||||
askpassLeases.set(leaseId, options.resolveWebContents);
|
||||
}
|
||||
const env = {
|
||||
SSH_ASKPASS: artifacts.wrapperPath,
|
||||
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,
|
||||
@@ -485,6 +522,8 @@ module.exports = {
|
||||
classifyAskpassPrompt: fidoPromptHandler.classifyAskpassPrompt,
|
||||
resolveFidoAskpassSocketPath,
|
||||
isWindowsNamedPipePath,
|
||||
selectFidoAskpassWrapper,
|
||||
isWindowsPosixSsh,
|
||||
// exposed for tests
|
||||
getTempBase,
|
||||
handleAskpassClient,
|
||||
|
||||
@@ -14,7 +14,7 @@ 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-"));
|
||||
const managedTemp = fs.mkdtempSync(path.join(tempDirBridge.getTempDir(), "fp 'q$-"));
|
||||
const originalGetTempDir = tempDirBridge.getTempDir;
|
||||
tempDirBridge.getTempDir = () => managedTemp;
|
||||
const {
|
||||
|
||||
@@ -12,6 +12,7 @@ const {
|
||||
} = 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();
|
||||
@@ -54,3 +55,35 @@ test("releaseFidoAskpassLease cancels outstanding FIDO prompts for that lease",
|
||||
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(); }
|
||||
});
|
||||
@@ -6,13 +6,27 @@ 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), false);
|
||||
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", () => {
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
});
|
||||
@@ -66,11 +66,12 @@ function cancelFidoPromptRequest(requestId, reason = "cancelled") {
|
||||
* @param {string} [reason]
|
||||
* @returns {number}
|
||||
*/
|
||||
function cancelFidoPromptRequestsForLease(leaseId, reason = "lease-released") {
|
||||
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;
|
||||
@@ -116,6 +117,7 @@ function requestFidoPrompt(sender, options = {}) {
|
||||
webContentsId: sender.id,
|
||||
kind,
|
||||
leaseId,
|
||||
notificationOnly: options.notificationOnly === true,
|
||||
createdAt: Date.now(),
|
||||
timeoutId,
|
||||
});
|
||||
|
||||
@@ -90,6 +90,8 @@ async function generateFidoSshKeyPair(options = {}) {
|
||||
const { buildFidoAskpassEnv } = require("./fidoAskpass.cjs");
|
||||
askpassEnv = buildFidoAskpassEnv({
|
||||
resolveWebContents: options.resolveWebContents,
|
||||
sshBinaryPath: sshKeygen,
|
||||
env,
|
||||
});
|
||||
askpassLeaseId = askpassEnv.NETCATTY_FIDO_ASKPASS_LEASE || null;
|
||||
} catch {
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
};
|
||||
|
||||
+191
-189
@@ -1086,21 +1086,26 @@ async function materializeSkPrivateKeyFile(privateKey, injected = {}) {
|
||||
}
|
||||
const baseDir = tempDirBridge.getTempDir();
|
||||
const dir = await fsMod.promises.mkdtemp(pathMod.join(baseDir, "netcatty-sk-key-"));
|
||||
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 });
|
||||
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;
|
||||
}
|
||||
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 };
|
||||
}
|
||||
|
||||
async function prepareSystemSshAgentForAuth(options, logPrefix = "[SSHAuth]") {
|
||||
@@ -1204,66 +1209,16 @@ async function prepareSystemSshAgentForAuth(options, logPrefix = "[SSHAuth]") {
|
||||
? options.agentPublicKeys.find((key) => looksLikeSkOpenSshMaterial(key))
|
||||
: null;
|
||||
let skTempCleanup = null;
|
||||
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),
|
||||
});
|
||||
}
|
||||
} 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),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/** @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
|
||||
@@ -1276,40 +1231,27 @@ async function prepareSystemSshAgentForAuth(options, logPrefix = "[SSHAuth]") {
|
||||
releaseSharedAgentIdentity,
|
||||
} = require("./systemSshAgent.cjs");
|
||||
const sshAdd = fidoSshAddPath || resolveSshAddBinary(process.platform, process.env);
|
||||
for (const entry of sharedAgentIdentities) {
|
||||
const { shouldRemove, identityPath, cleanupDir } = releaseSharedAgentIdentity(entry.key);
|
||||
if (!shouldRemove) continue;
|
||||
const removeCleanupDir = () => {
|
||||
if (!cleanupDir) return;
|
||||
fs.promises.rm(cleanupDir, { recursive: true, force: true }).catch(() => {});
|
||||
};
|
||||
if (identityPath) {
|
||||
try {
|
||||
// ssh-add -d treats operands as identity-file paths; keep the
|
||||
// staging dir until the child finishes (or fails to spawn).
|
||||
childProcess.execFile(
|
||||
sshAdd,
|
||||
["-d", identityPath],
|
||||
{
|
||||
timeout: 5000,
|
||||
windowsHide: true,
|
||||
env: {
|
||||
...process.env,
|
||||
SSH_AUTH_SOCK: socketPath,
|
||||
SSH_ASKPASS_REQUIRE: "never",
|
||||
},
|
||||
},
|
||||
() => {
|
||||
removeCleanupDir();
|
||||
},
|
||||
);
|
||||
} catch {
|
||||
removeCleanupDir();
|
||||
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(); }
|
||||
});
|
||||
}
|
||||
} else {
|
||||
removeCleanupDir();
|
||||
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).
|
||||
@@ -1330,103 +1272,163 @@ async function prepareSystemSshAgentForAuth(options, logPrefix = "[SSHAuth]") {
|
||||
};
|
||||
|
||||
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;
|
||||
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);
|
||||
// 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;
|
||||
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;
|
||||
}
|
||||
require("./fidoSignFlow.cjs").signWithRecovery(context, options,
|
||||
settle => sign(key, data, signOptions, settle)).then(result => {
|
||||
}
|
||||
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(result.error, result.signature);
|
||||
}, error => { finish(); callback(error); });
|
||||
});
|
||||
};
|
||||
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();
|
||||
}
|
||||
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;
|
||||
return agent;
|
||||
});
|
||||
} catch (error) {
|
||||
// prepareSystemSshAgent can throw after acquire (e.g. IdentitiesOnly without
|
||||
// a readable .pub selector); release owned agent + askpass before rethrowing.
|
||||
|
||||
@@ -189,6 +189,59 @@ test("materializeSkPrivateKeyFile fails closed without managed temp dir", async
|
||||
);
|
||||
});
|
||||
|
||||
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");
|
||||
@@ -307,8 +360,10 @@ test("owned FIDO agent reference-counts identities: ssh-add -d only on the last
|
||||
|
||||
// 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;
|
||||
@@ -378,6 +433,7 @@ test("prepareSystemSshAgentForAuth removes newly loaded identities from a shared
|
||||
assert.ok(agent);
|
||||
assert.equal(typeof agent._releaseNetcattyFidoAgent, "function");
|
||||
agent._releaseNetcattyFidoAgent();
|
||||
await new Promise(setImmediate);
|
||||
assert.deepEqual(deleted, [identityPath]);
|
||||
assert.equal(releasedAgent, 1);
|
||||
} finally {
|
||||
@@ -462,8 +518,10 @@ test("shared Windows agent defers ssh-add -d until the last identity user releas
|
||||
}, "[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;
|
||||
@@ -505,8 +563,8 @@ test("shared Windows agent keeps staging dir until ssh-add -d finishes", async (
|
||||
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) => (
|
||||
originalRetain(key, idPath, cleanupDir || stagingDir)
|
||||
systemSshAgent.retainSharedAgentIdentity = (key, idPath, cleanupDir, socketPath) => (
|
||||
originalRetain(key, idPath, cleanupDir || stagingDir, socketPath)
|
||||
);
|
||||
systemSshAgent.prepareSystemSshAgent = async () => ({
|
||||
getIdentities: (cb) => cb(null, []),
|
||||
@@ -535,6 +593,7 @@ test("shared Windows agent keeps staging dir until ssh-add -d finishes", async (
|
||||
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),
|
||||
|
||||
@@ -264,7 +264,11 @@ function createMoshStatsConnectionApi(ctx) {
|
||||
}
|
||||
|
||||
if (!agent && auth.useSshAgent && typeof prepareSystemSshAgentForAuth === "function") {
|
||||
const prepared = 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
|
||||
|
||||
@@ -385,6 +385,24 @@ test("path-backed sk-* key handles are filtered out of the companion's identity
|
||||
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
|
||||
|
||||
@@ -25,23 +25,51 @@ function publicKeyBlob(key) {
|
||||
* Shared (e.g. Windows system-pipe) agent identity holds. Concurrent sessions
|
||||
* that materialize the same SK key get unique temp paths, so cleanup must be
|
||||
* keyed by public identity blob and only `ssh-add -d` when the last user leaves.
|
||||
* @type {Map<string, { refCount: number, identityPath: string, cleanupDir: string|null }>}
|
||||
* @type {Map<string, { refCount: number, identityPath: string, cleanupDir: string|null, plain?: boolean }>}
|
||||
*/
|
||||
const sharedAgentIdentityRefs = new Map();
|
||||
const agentIdentityLocks = new Map();
|
||||
const sharedCleanupDirs = new Map();
|
||||
|
||||
function withAgentIdentityLock(socketPath, operation) {
|
||||
const previous = agentIdentityLocks.get(socketPath) || Promise.resolve();
|
||||
const result = previous.catch(() => {}).then(operation);
|
||||
const tail = result.catch(() => {});
|
||||
agentIdentityLocks.set(socketPath, tail);
|
||||
void tail.then(() => {
|
||||
if (agentIdentityLocks.get(socketPath) === tail) agentIdentityLocks.delete(socketPath);
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
function identityRefKey(key, socketPath) {
|
||||
return socketPath ? `${socketPath}\0${key}` : key;
|
||||
}
|
||||
|
||||
function finishSharedCleanupDir(cleanupDir) {
|
||||
if (!cleanupDir) return null;
|
||||
const remaining = (sharedCleanupDirs.get(cleanupDir) || 1) - 1;
|
||||
if (remaining > 0) { sharedCleanupDirs.set(cleanupDir, remaining); return null; }
|
||||
sharedCleanupDirs.delete(cleanupDir);
|
||||
return cleanupDir;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} key Public identity blob (or path fallback)
|
||||
* @param {string} identityPath Path suitable for `ssh-add -d`
|
||||
* @param {string|null} [cleanupDir] Temp dir to remove with the last release
|
||||
* @param {string} [socketPath] Agent owning this identity
|
||||
* @param {boolean} [plain] Remove the bare key with ssh-add -k, preserving certificates
|
||||
* @returns {{ entry: { refCount: number, identityPath: string, cleanupDir: string|null }, acceptedCleanup: boolean }|null}
|
||||
* `acceptedCleanup` is true only when this call recorded `cleanupDir` on a new
|
||||
* map entry. Reuses increment refcount but do not adopt a second staging dir.
|
||||
*/
|
||||
function retainSharedAgentIdentity(key, identityPath, cleanupDir = null) {
|
||||
function retainSharedAgentIdentity(key, identityPath, cleanupDir = null, socketPath = "", plain = false) {
|
||||
if (typeof key !== "string" || !key || typeof identityPath !== "string" || !identityPath) {
|
||||
return null;
|
||||
}
|
||||
const existing = sharedAgentIdentityRefs.get(key);
|
||||
const refKey = identityRefKey(key, socketPath);
|
||||
const existing = sharedAgentIdentityRefs.get(refKey);
|
||||
if (existing) {
|
||||
existing.refCount += 1;
|
||||
// First retainer owns identityPath + cleanupDir for ssh-add -d / rm.
|
||||
@@ -52,20 +80,24 @@ function retainSharedAgentIdentity(key, identityPath, cleanupDir = null) {
|
||||
refCount: 1,
|
||||
identityPath,
|
||||
cleanupDir: normalizedCleanup,
|
||||
...(plain ? { plain: true } : {}),
|
||||
};
|
||||
sharedAgentIdentityRefs.set(key, entry);
|
||||
sharedAgentIdentityRefs.set(refKey, entry);
|
||||
if (normalizedCleanup) sharedCleanupDirs.set(normalizedCleanup, (sharedCleanupDirs.get(normalizedCleanup) || 0) + 1);
|
||||
return { entry, acceptedCleanup: Boolean(normalizedCleanup) };
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} key
|
||||
* @param {string} [socketPath]
|
||||
* @returns {{ shouldRemove: boolean, identityPath: string|null, cleanupDir: string|null }}
|
||||
*/
|
||||
function releaseSharedAgentIdentity(key) {
|
||||
function releaseSharedAgentIdentity(key, socketPath = "") {
|
||||
if (typeof key !== "string" || !key) {
|
||||
return { shouldRemove: false, identityPath: null, cleanupDir: null };
|
||||
}
|
||||
const existing = sharedAgentIdentityRefs.get(key);
|
||||
const refKey = identityRefKey(key, socketPath);
|
||||
const existing = sharedAgentIdentityRefs.get(refKey);
|
||||
if (!existing) return { shouldRemove: false, identityPath: null, cleanupDir: null };
|
||||
existing.refCount -= 1;
|
||||
if (existing.refCount > 0) {
|
||||
@@ -75,9 +107,10 @@ function releaseSharedAgentIdentity(key) {
|
||||
cleanupDir: existing.cleanupDir,
|
||||
};
|
||||
}
|
||||
sharedAgentIdentityRefs.delete(key);
|
||||
sharedAgentIdentityRefs.delete(refKey);
|
||||
return {
|
||||
shouldRemove: true,
|
||||
...(existing.plain ? { plain: true } : {}),
|
||||
identityPath: existing.identityPath,
|
||||
cleanupDir: existing.cleanupDir,
|
||||
};
|
||||
@@ -87,12 +120,13 @@ function releaseSharedAgentIdentity(key) {
|
||||
* @param {string} key
|
||||
* @returns {boolean}
|
||||
*/
|
||||
function hasSharedAgentIdentity(key) {
|
||||
return typeof key === "string" && key.length > 0 && sharedAgentIdentityRefs.has(key);
|
||||
function hasSharedAgentIdentity(key, socketPath = "") {
|
||||
return typeof key === "string" && key.length > 0 && sharedAgentIdentityRefs.has(identityRefKey(key, socketPath));
|
||||
}
|
||||
|
||||
function resetSharedAgentIdentityRefsForTests() {
|
||||
sharedAgentIdentityRefs.clear();
|
||||
sharedCleanupDirs.clear();
|
||||
}
|
||||
|
||||
function resolveIdentityPath(rawPath, context = {}) {
|
||||
@@ -133,6 +167,7 @@ function resolveIdentityPath(rawPath, context = {}) {
|
||||
|
||||
async function loadPreferredPublicKeyBlobs(identityFilePaths, publicKeys, options, deps) {
|
||||
const preferred = new Set();
|
||||
const identityBlobs = new Map();
|
||||
const resolvedIdentityPaths = [];
|
||||
const unavailablePublicKeyPaths = [];
|
||||
let providedPreferredCount = 0;
|
||||
@@ -151,9 +186,18 @@ async function loadPreferredPublicKeyBlobs(identityFilePaths, publicKeys, option
|
||||
try {
|
||||
const contents = await deps.readFile(publicKeyPath, "utf8");
|
||||
const blob = publicKeyBlob(contents);
|
||||
if (blob) preferred.add(blob);
|
||||
if (blob) { preferred.add(blob); identityBlobs.set(identityPath, blob); }
|
||||
else unavailablePublicKeyPaths.push(publicKeyPath);
|
||||
} catch (error) {
|
||||
// An unencrypted SK handle contains its public identity. Keep strict
|
||||
// filtering even when the optional companion .pub file is absent.
|
||||
try {
|
||||
const key = utils.parseKey(await deps.readFile(identityPath, "utf8"));
|
||||
if (!(key instanceof Error) && key.type?.startsWith("sk-")) {
|
||||
const blob = publicKeyBlob(key);
|
||||
if (blob) { preferred.add(blob); identityBlobs.set(identityPath, blob); continue; }
|
||||
}
|
||||
} catch { /* retain the missing-selector error below */ }
|
||||
unavailablePublicKeyPaths.push(publicKeyPath);
|
||||
deps.log?.("Configured SSH public key is unavailable", {
|
||||
publicKeyPath,
|
||||
@@ -161,7 +205,7 @@ async function loadPreferredPublicKeyBlobs(identityFilePaths, publicKeys, option
|
||||
});
|
||||
}
|
||||
}
|
||||
return { preferred, providedPreferredCount, resolvedIdentityPaths, unavailablePublicKeyPaths };
|
||||
return { preferred, providedPreferredCount, resolvedIdentityPaths, unavailablePublicKeyPaths, identityBlobs };
|
||||
}
|
||||
|
||||
function getIdentities(agent) {
|
||||
@@ -335,7 +379,7 @@ async function prepareSystemSshAgent(options, injected = {}) {
|
||||
askpassEnv: injected.askpassEnv,
|
||||
};
|
||||
const agent = deps.createAgent(options.socketPath);
|
||||
const { preferred, providedPreferredCount, resolvedIdentityPaths, unavailablePublicKeyPaths } = await loadPreferredPublicKeyBlobs(
|
||||
const { preferred, providedPreferredCount, resolvedIdentityPaths, unavailablePublicKeyPaths, identityBlobs } = await loadPreferredPublicKeyBlobs(
|
||||
options.identityFilePaths,
|
||||
options.agentPublicKeys,
|
||||
{ hostname: options.hostname, port: options.port, username: options.username, env: deps.env },
|
||||
@@ -397,11 +441,12 @@ async function prepareSystemSshAgent(options, injected = {}) {
|
||||
const pubPath = identityPath.endsWith(".pub") ? identityPath : `${identityPath}.pub`;
|
||||
const certPath = identityPath.endsWith(".pub") ? null : `${identityPath}-cert.pub`;
|
||||
/** @type {string|null} */
|
||||
let blob = null;
|
||||
let blob = identityBlobs.get(identityPath) || null;
|
||||
/** @type {string|null} */
|
||||
let certBlob = null;
|
||||
let bareAlreadyPresent = false;
|
||||
let bareAlreadyPresent = Boolean(blob && loadedBlobs.has(blob));
|
||||
let companionCertificateMissing = false;
|
||||
let certificateAlreadyPresent = false;
|
||||
try {
|
||||
const pub = await deps.readFile(pubPath, "utf8");
|
||||
blob = publicKeyBlob(pub);
|
||||
@@ -409,14 +454,15 @@ async function prepareSystemSshAgent(options, injected = {}) {
|
||||
} catch {
|
||||
// No .pub selector — still attempt ssh-add when requested.
|
||||
}
|
||||
if (bareAlreadyPresent && certPath) {
|
||||
if (certPath) {
|
||||
// OpenSSH ssh-add loads `<identity>-cert.pub` with the private handle.
|
||||
// If only the bare key is already advertised, still run ssh-add so a
|
||||
// staged vault certificate reaches the agent.
|
||||
try {
|
||||
const certPub = await deps.readFile(certPath, "utf8");
|
||||
certBlob = publicKeyBlob(certPub);
|
||||
if (certBlob && !loadedBlobs.has(certBlob)) {
|
||||
certificateAlreadyPresent = Boolean(certBlob && loadedBlobs.has(certBlob));
|
||||
if (certBlob && !certificateAlreadyPresent) {
|
||||
companionCertificateMissing = true;
|
||||
}
|
||||
} catch {
|
||||
@@ -428,8 +474,11 @@ async function prepareSystemSshAgent(options, injected = {}) {
|
||||
// session loaded it). Pre-existing user agent identities must not be
|
||||
// adopted into cleanup — last release would ssh-add -d them.
|
||||
const key = blob || identityPath;
|
||||
if (hasSharedAgentIdentity(key)) {
|
||||
sharedAgentIdentities.push({ key, identityPath });
|
||||
if (hasSharedAgentIdentity(key, options.socketPath)) {
|
||||
sharedAgentIdentities.push({ key, identityPath, plain: true });
|
||||
}
|
||||
if (certBlob && hasSharedAgentIdentity(certBlob, options.socketPath)) {
|
||||
sharedAgentIdentities.push({ key: certBlob, identityPath: certPath });
|
||||
}
|
||||
continue;
|
||||
}
|
||||
@@ -465,21 +514,17 @@ async function prepareSystemSshAgent(options, injected = {}) {
|
||||
}
|
||||
}
|
||||
const key = blob || identityPath;
|
||||
if (bareAlreadyPresent) {
|
||||
// Reloaded only to advertise a missing companion certificate.
|
||||
if (hasSharedAgentIdentity(key)) {
|
||||
// Another Netcatty session already owns the bare identity; join its
|
||||
// refcount so the last release can ssh-add -d the private handle.
|
||||
sharedAgentIdentities.push({ key, identityPath });
|
||||
} else if (certBlob && certPath) {
|
||||
// Bare key was already in the shared agent (user-managed). Track the
|
||||
// certificate alone so cleanup removes only what we added.
|
||||
newlyLoadedIdentityPaths.push(certPath);
|
||||
if (!bareAlreadyPresent) {
|
||||
newlyLoadedIdentityPaths.push(identityPath);
|
||||
sharedAgentIdentities.push({ key, identityPath, plain: true });
|
||||
} else if (hasSharedAgentIdentity(key, options.socketPath)) {
|
||||
sharedAgentIdentities.push({ key, identityPath, plain: true });
|
||||
}
|
||||
if (certBlob && certPath) {
|
||||
if (!certificateAlreadyPresent) newlyLoadedIdentityPaths.push(certPath);
|
||||
if (!certificateAlreadyPresent || hasSharedAgentIdentity(certBlob, options.socketPath)) {
|
||||
sharedAgentIdentities.push({ key: certBlob, identityPath: certPath });
|
||||
}
|
||||
} else {
|
||||
newlyLoadedIdentityPaths.push(identityPath);
|
||||
sharedAgentIdentities.push({ key, identityPath });
|
||||
}
|
||||
deps.log?.("Loaded identity into SSH agent", { identityPath });
|
||||
} catch (error) {
|
||||
@@ -499,7 +544,8 @@ async function prepareSystemSshAgent(options, injected = {}) {
|
||||
// into a shared (e.g. Windows system) agent.
|
||||
for (const identityPath of newlyLoadedIdentityPaths) {
|
||||
try {
|
||||
const args = ["-d", identityPath];
|
||||
const plain = sharedAgentIdentities.some(entry => entry.identityPath === identityPath && entry.plain);
|
||||
const args = plain ? ["-d", "-k", identityPath] : ["-d", identityPath];
|
||||
if (deps.runSshAdd) await deps.runSshAdd(args);
|
||||
else {
|
||||
await defaultRunSshAdd(args, {
|
||||
@@ -534,6 +580,8 @@ async function prepareSystemSshAgent(options, injected = {}) {
|
||||
|
||||
module.exports = {
|
||||
IdentityAwareAgent,
|
||||
withAgentIdentityLock,
|
||||
finishSharedCleanupDir,
|
||||
prepareSystemSshAgent,
|
||||
publicKeyBlob,
|
||||
resolveIdentityPath,
|
||||
|
||||
@@ -10,6 +10,38 @@ const { parseKey } = require("ssh2/lib/protocol/keyParser.js");
|
||||
|
||||
const SK_SSH_ED25519 = "sk-ssh-ed25519@openssh.com";
|
||||
|
||||
test("identity preparation waits for deletion on the same socket, without blocking other agents", async () => {
|
||||
const { withAgentIdentityLock } = require("./systemSshAgent.cjs");
|
||||
let finishDelete;
|
||||
let inspected = false;
|
||||
const deletion = withAgentIdentityLock("agent-a", () => new Promise(resolve => { finishDelete = resolve; }));
|
||||
await new Promise(setImmediate);
|
||||
const preparation = withAgentIdentityLock("agent-a", async () => { inspected = true; });
|
||||
await withAgentIdentityLock("agent-b", async () => {});
|
||||
assert.equal(inspected, false, "a new login cannot borrow an identity pending removal");
|
||||
finishDelete();
|
||||
await Promise.all([deletion, preparation]);
|
||||
assert.equal(inspected, true);
|
||||
});
|
||||
|
||||
test("identity holds are scoped to their agent and retain staging until bare key and certificate removal finish", () => {
|
||||
const { retainSharedAgentIdentity, releaseSharedAgentIdentity, finishSharedCleanupDir,
|
||||
resetSharedAgentIdentityRefsForTests } = require("./systemSshAgent.cjs");
|
||||
resetSharedAgentIdentityRefsForTests();
|
||||
try {
|
||||
retainSharedAgentIdentity("bare", "/tmp/key", "/tmp/stage", "agent-a", true);
|
||||
retainSharedAgentIdentity("cert", "/tmp/cert.pub", "/tmp/stage", "agent-a");
|
||||
retainSharedAgentIdentity("bare", "/tmp/other-key", null, "agent-b", true);
|
||||
const bare = releaseSharedAgentIdentity("bare", "agent-a");
|
||||
assert.equal(bare.shouldRemove, true);
|
||||
assert.equal(bare.plain, true, "bare removal must skip companion certificates");
|
||||
assert.equal(finishSharedCleanupDir(bare.cleanupDir), null);
|
||||
const cert = releaseSharedAgentIdentity("cert", "agent-a");
|
||||
assert.equal(finishSharedCleanupDir(cert.cleanupDir), "/tmp/stage");
|
||||
assert.equal(releaseSharedAgentIdentity("bare", "agent-b").identityPath, "/tmp/other-key");
|
||||
} finally { resetSharedAgentIdentityRefsForTests(); }
|
||||
});
|
||||
|
||||
function makeSkPrivatePem() {
|
||||
const body = Buffer.from(`openssh-key-v1\0\0\0\0\0none\0\0\0\0\0\0\0\0\0\x01${SK_SSH_ED25519}`).toString("base64");
|
||||
const pem = `-----BEGIN OPENSSH PRIVATE KEY-----\n${body}\n-----END OPENSSH PRIVATE KEY-----\n`;
|
||||
@@ -124,7 +156,7 @@ test("prepareSystemSshAgent tracks newly loaded identities for shared-agent clea
|
||||
assert.deepEqual(sshAddCalls, [[identityPath]]);
|
||||
assert.deepEqual(prepared._netcattyNewlyLoadedIdentityPaths, [identityPath]);
|
||||
assert.deepEqual(prepared._netcattySharedAgentIdentities, [
|
||||
{ key: publicKeyBlob(pubLine), identityPath },
|
||||
{ key: publicKeyBlob(pubLine), identityPath, plain: true },
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -243,7 +275,7 @@ test("prepareSystemSshAgent ssh-adds when bare identity is loaded but companion
|
||||
};
|
||||
|
||||
try {
|
||||
retainSharedAgentIdentity(blob, "/tmp/id_ed25519_sk_first");
|
||||
retainSharedAgentIdentity(blob, "/tmp/id_ed25519_sk_first", null, "/tmp/fake-agent.sock");
|
||||
const prepared = await prepareSystemSshAgent({
|
||||
socketPath: "/tmp/fake-agent.sock",
|
||||
identityFilePaths: [identityPath],
|
||||
@@ -262,9 +294,10 @@ test("prepareSystemSshAgent ssh-adds when bare identity is loaded but companion
|
||||
assert.deepEqual(sshAddCalls, [[identityPath]]);
|
||||
// Bare key was already Netcatty-owned; join that refcount (do not treat
|
||||
// the cert-only reload as a newly loaded private identity).
|
||||
assert.equal(prepared._netcattyNewlyLoadedIdentityPaths, undefined);
|
||||
assert.deepEqual(prepared._netcattyNewlyLoadedIdentityPaths, [certPath]);
|
||||
assert.deepEqual(prepared._netcattySharedAgentIdentities, [
|
||||
{ key: blob, identityPath },
|
||||
{ key: blob, identityPath, plain: true },
|
||||
{ key: certBlob, identityPath: certPath },
|
||||
]);
|
||||
} finally {
|
||||
resetSharedAgentIdentityRefsForTests();
|
||||
@@ -387,7 +420,7 @@ test("prepareSystemSshAgent joins Netcatty-owned already-loaded identities for s
|
||||
};
|
||||
|
||||
try {
|
||||
retainSharedAgentIdentity(blob, "/tmp/id_ed25519_sk_first");
|
||||
retainSharedAgentIdentity(blob, "/tmp/id_ed25519_sk_first", null, "/tmp/fake-agent.sock");
|
||||
const prepared = await prepareSystemSshAgent({
|
||||
socketPath: "/tmp/fake-agent.sock",
|
||||
identityFilePaths: [identityPath],
|
||||
@@ -406,7 +439,7 @@ test("prepareSystemSshAgent joins Netcatty-owned already-loaded identities for s
|
||||
|
||||
assert.equal(prepared._netcattyNewlyLoadedIdentityPaths, undefined);
|
||||
assert.deepEqual(prepared._netcattySharedAgentIdentities, [
|
||||
{ key: blob, identityPath },
|
||||
{ key: blob, identityPath, plain: true },
|
||||
]);
|
||||
} finally {
|
||||
resetSharedAgentIdentityRefsForTests();
|
||||
|
||||
@@ -165,7 +165,7 @@ test("Mosh prepares the configured system agent before building native ssh optio
|
||||
assert.deepEqual(auth.sshArgs, [
|
||||
"-i", path.join(os.homedir(), ".ssh", "id_work.pub"),
|
||||
"-o", "IdentitiesOnly=yes",
|
||||
"-o", "IdentityAgent=/tmp/custom-agent.sock",
|
||||
"-o", 'IdentityAgent="/tmp/custom-agent.sock"',
|
||||
"-o", "StrictHostKeyChecking=ask",
|
||||
]);
|
||||
assert.equal(env.SSH_AUTH_SOCK, "/tmp/custom-agent.sock");
|
||||
|
||||
@@ -176,7 +176,7 @@ main();
|
||||
let releasePreparedAgent = null;
|
||||
try {
|
||||
if (connectionOptions.useSshAgent === true || forceFido) {
|
||||
const agent = await prepareSystemSshAgentForAuth(prepOptions, logPrefix);
|
||||
const agent = await prepareSystemSshAgentForAuth({ ...prepOptions, nativeFidoAgent: true, nativeSshPath: process.platform === "win32" ? findExecutable("ssh") : undefined }, logPrefix);
|
||||
if (typeof agent?._releaseNetcattyFidoAgent === "function") {
|
||||
releasePreparedAgent = agent._releaseNetcattyFidoAgent;
|
||||
}
|
||||
|
||||
@@ -145,7 +145,7 @@ function createMoshSessionApi(ctx) {
|
||||
}
|
||||
}
|
||||
|
||||
async function prepareMoshSshAgentOptions(options, sender) {
|
||||
async function prepareMoshSshAgentOptions(options, sender, sshExe) {
|
||||
const {
|
||||
enhanceAuthOptionsForFido,
|
||||
resolvePreparedAgentSocket,
|
||||
@@ -158,7 +158,7 @@ function createMoshSessionApi(ctx) {
|
||||
let releasePreparedAgent = null;
|
||||
try {
|
||||
if (options.useSshAgent === true || forceFido) {
|
||||
const agent = await prepareSystemSshAgentForAuth(prepOptions, "[Mosh]");
|
||||
const agent = await prepareSystemSshAgentForAuth({ ...prepOptions, nativeFidoAgent: true, nativeSshPath: sshExe }, "[Mosh]");
|
||||
if (typeof agent?._releaseNetcattyFidoAgent === "function") {
|
||||
releasePreparedAgent = agent._releaseNetcattyFidoAgent;
|
||||
}
|
||||
@@ -412,7 +412,9 @@ function createMoshSessionApi(ctx) {
|
||||
if (options.useSshAgent === false) {
|
||||
sshArgs.push("-o", "IdentityAgent=none");
|
||||
} else if (options.useSshAgent && options._resolvedSshAgentSocket) {
|
||||
sshArgs.push("-o", `IdentityAgent=${options._resolvedSshAgentSocket}`);
|
||||
// -o is parsed again as ssh_config syntax, even with argv spawning.
|
||||
const quotedSocket = `"${String(options._resolvedSshAgentSocket).replace(/(["\\])/g, "\\$1")}"`;
|
||||
sshArgs.push("-o", `IdentityAgent=${quotedSocket}`);
|
||||
if (options.identitiesOnly && !sshArgs.includes("IdentitiesOnly=yes")) {
|
||||
sshArgs.push("-o", "IdentitiesOnly=yes");
|
||||
}
|
||||
@@ -1011,7 +1013,7 @@ function createMoshSessionApi(ctx) {
|
||||
throw new Error("OpenSSH client not found. Netcatty needs ssh to start the remote mosh-server handshake.");
|
||||
}
|
||||
|
||||
const preparedOptions = await prepareMoshSshAgentOptions(options, event?.sender);
|
||||
const preparedOptions = await prepareMoshSshAgentOptions(options, event?.sender, sshExe);
|
||||
return startMoshSessionViaHandshake(event, preparedOptions, { bareClient, sshExe });
|
||||
}
|
||||
|
||||
|
||||
@@ -139,7 +139,7 @@ test("failed copy followed by exclusive-open collision leaves that entry intact"
|
||||
|
||||
for (const metadataError of ["ENOTSUP", "EPERM", "EACCES"]) {
|
||||
test(`rollback retains backup when its original timestamps cannot be restored: ${metadataError}`, async (t) => {
|
||||
const setup = await mountedDestination(t, { existingMode: 0o644, cancelBeforeFinal: true, metadataError });
|
||||
const setup = await mountedDestination(t, { existingMode: 0o644, virtualMode: 0o644, cancelBeforeFinal: true, metadataError });
|
||||
await assert.rejects(setup.run, /Recovery files preserved/);
|
||||
const backup = fs.readdirSync(setup.root).find(name => name.endsWith(".backup"));
|
||||
assert.ok(backup);
|
||||
|
||||
@@ -312,12 +312,17 @@ function createVaultBackupService({ app, safeStorage, shell }) {
|
||||
|
||||
const getBackupDir = () => path.join(app.getPath("userData"), BACKUP_DIR_NAME);
|
||||
|
||||
// Serialize createBackup so two concurrent calls (version-change backup
|
||||
// Serialize backup mutations so two concurrent calls (version-change backup
|
||||
// running at startup + an explicit protective-before-restore triggered
|
||||
// by the user's click, etc.) observe each other's writes. Without this,
|
||||
// both observers would see an empty directory, compute the same
|
||||
// fingerprint, skip the dedupe, and write two identical files.
|
||||
let createBackupLock = Promise.resolve();
|
||||
let mutationLock = Promise.resolve();
|
||||
const mutate = (operation) => {
|
||||
const next = mutationLock.then(operation);
|
||||
mutationLock = next.catch(() => undefined);
|
||||
return next;
|
||||
};
|
||||
// Monotonically increasing `createdAt` per service instance. `Date.now()`
|
||||
// has 1ms resolution and back-to-back async calls (version-change backup
|
||||
// followed immediately by a protective backup) can land in the same
|
||||
@@ -332,12 +337,7 @@ function createVaultBackupService({ app, safeStorage, shell }) {
|
||||
},
|
||||
|
||||
async createBackup(options = {}) {
|
||||
const next = createBackupLock.then(() => doCreateBackup(options));
|
||||
// Swallow the rejection on the lock chain so one caller's error
|
||||
// does not poison subsequent calls; each individual await sees its
|
||||
// own rejection via the `next` return.
|
||||
createBackupLock = next.catch(() => undefined);
|
||||
return next;
|
||||
return mutate(() => doCreateBackup(options));
|
||||
},
|
||||
|
||||
async listBackups() {
|
||||
@@ -364,7 +364,44 @@ function createVaultBackupService({ app, safeStorage, shell }) {
|
||||
},
|
||||
|
||||
async trimBackups(options = {}) {
|
||||
return pruneBackupRecords(getBackupDir(), options.maxCount);
|
||||
return mutate(() => pruneBackupRecords(getBackupDir(), options.maxCount));
|
||||
},
|
||||
|
||||
async clearBackups() {
|
||||
return mutate(async () => {
|
||||
const dirPath = getBackupDir();
|
||||
await fs.promises.mkdir(dirPath, { recursive: true, mode: 0o700 });
|
||||
const entries = await fs.promises.readdir(dirPath, { withFileTypes: true });
|
||||
let deletedCount = 0;
|
||||
let failedCount = 0;
|
||||
for (const entry of entries) {
|
||||
// Include unreadable records and interrupted writes without reading
|
||||
// or decrypting them. Preserve unrelated files and directories.
|
||||
if (!entry.isFile() && !entry.isSymbolicLink()) continue;
|
||||
if (!entry.name.startsWith(BACKUP_FILE_PREFIX)) continue;
|
||||
if (!entry.name.endsWith(BACKUP_FILE_EXT)
|
||||
&& !entry.name.includes(`${BACKUP_FILE_EXT}.tmp-`)) continue;
|
||||
try {
|
||||
await fs.promises.unlink(path.join(dirPath, entry.name));
|
||||
deletedCount += 1;
|
||||
} catch (error) {
|
||||
if (error.code !== "ENOENT") failedCount += 1;
|
||||
}
|
||||
}
|
||||
// Persist directory changes before reporting success, including on a
|
||||
// retry after a previous sync failure left no backup files to unlink.
|
||||
// Windows does not support opening directory handles this way.
|
||||
if (process.platform !== 'win32') {
|
||||
const dirHandle = await fs.promises.open(dirPath, 'r');
|
||||
try {
|
||||
await dirHandle.sync();
|
||||
} finally {
|
||||
await dirHandle.close();
|
||||
}
|
||||
}
|
||||
if (failedCount) throw new Error(`Could not delete ${failedCount} vault backup file(s).`);
|
||||
return { deletedCount };
|
||||
});
|
||||
},
|
||||
|
||||
async openBackupDir() {
|
||||
@@ -577,6 +614,14 @@ function registerHandlers(ipcMain, electronModule) {
|
||||
}
|
||||
return result;
|
||||
});
|
||||
ipcMain.handle("netcatty:vaultBackups:clear", async () => {
|
||||
try {
|
||||
return await service.clearBackups();
|
||||
} finally {
|
||||
// Refresh every window after partial deletion as well as full success.
|
||||
broadcastBackupsChanged();
|
||||
}
|
||||
});
|
||||
ipcMain.handle("netcatty:vaultBackups:openDir", async () => {
|
||||
return service.openBackupDir();
|
||||
});
|
||||
|
||||
@@ -716,3 +716,100 @@ test("readBackupRecord rejects oversized files before buffering them", async ()
|
||||
fs.rmSync(rootDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('clearBackups removes encrypted, corrupt and interrupted backup files only', async () => {
|
||||
const root = createTempRoot();
|
||||
try {
|
||||
const service = createService(root);
|
||||
await service.createBackup({ payload: samplePayload() });
|
||||
const dir = path.join(root, BACKUP_DIR_NAME);
|
||||
fs.writeFileSync(path.join(dir, 'vault-backup-bad.json'), '{broken');
|
||||
fs.writeFileSync(path.join(dir, 'vault-backup-interrupted.json.tmp-abc'), 'secret');
|
||||
fs.writeFileSync(path.join(dir, 'keep.txt'), 'unrelated');
|
||||
fs.mkdirSync(path.join(dir, 'vault-backup-directory.json'));
|
||||
const unavailable = createService(root, { encrypted: false });
|
||||
assert.deepEqual(await unavailable.clearBackups(), { deletedCount: 3 });
|
||||
assert.deepEqual(await service.listBackups(), []);
|
||||
assert.deepEqual(fs.readdirSync(dir).sort(), ['keep.txt', 'vault-backup-directory.json']);
|
||||
assert.deepEqual(await service.clearBackups(), { deletedCount: 0 });
|
||||
} finally {
|
||||
fs.rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('clearBackups waits for pending creates and allows future backups', async () => {
|
||||
const root = createTempRoot();
|
||||
try {
|
||||
const service = createService(root);
|
||||
const create = service.createBackup({ payload: samplePayload() });
|
||||
const clear = service.clearBackups();
|
||||
await create;
|
||||
assert.deepEqual(await clear, { deletedCount: 1 });
|
||||
assert.deepEqual(await service.listBackups(), []);
|
||||
const next = await service.createBackup({ payload: samplePayload() });
|
||||
assert.equal(next.created, true);
|
||||
assert.equal((await service.listBackups()).length, 1);
|
||||
} finally {
|
||||
fs.rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('clearBackups reports partial deletion failures and permits retry', async (t) => {
|
||||
const root = createTempRoot();
|
||||
const originalUnlink = fs.promises.unlink;
|
||||
try {
|
||||
const service = createService(root);
|
||||
await service.createBackup({ payload: samplePayload() });
|
||||
const dir = path.join(root, BACKUP_DIR_NAME);
|
||||
const blocked = path.join(dir, 'vault-backup-blocked.json');
|
||||
fs.writeFileSync(blocked, '{broken');
|
||||
const mock = t.mock.method(fs.promises, 'unlink', async (target) => {
|
||||
if (target === blocked) throw Object.assign(new Error('denied'), { code: 'EACCES' });
|
||||
return originalUnlink(target);
|
||||
});
|
||||
await assert.rejects(service.clearBackups(), /Could not delete 1 vault backup file/);
|
||||
assert.deepEqual(fs.readdirSync(dir), ['vault-backup-blocked.json']);
|
||||
mock.mock.restore();
|
||||
assert.deepEqual(await service.clearBackups(), { deletedCount: 1 });
|
||||
assert.deepEqual(fs.readdirSync(dir), []);
|
||||
} finally {
|
||||
fs.rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('clearBackups persists deletion and retries a failed directory sync even when empty', {
|
||||
skip: process.platform === 'win32',
|
||||
}, async (t) => {
|
||||
const root = createTempRoot();
|
||||
try {
|
||||
const service = createService(root);
|
||||
await service.createBackup({ payload: samplePayload() });
|
||||
const dir = path.join(root, BACKUP_DIR_NAME);
|
||||
const originalOpen = fs.promises.open;
|
||||
let syncCalls = 0;
|
||||
let closeCalls = 0;
|
||||
t.mock.method(fs.promises, 'open', async (target, ...args) => {
|
||||
const handle = await originalOpen(target, ...args);
|
||||
if (target !== dir) return handle;
|
||||
return {
|
||||
async sync() {
|
||||
syncCalls += 1;
|
||||
assert.deepEqual(fs.readdirSync(dir), []);
|
||||
if (syncCalls === 1) throw new Error('fixture directory sync failed');
|
||||
await handle.sync();
|
||||
},
|
||||
async close() {
|
||||
closeCalls += 1;
|
||||
await handle.close();
|
||||
},
|
||||
};
|
||||
});
|
||||
await assert.rejects(service.clearBackups(), /fixture directory sync failed/);
|
||||
assert.equal(closeCalls, 1);
|
||||
assert.deepEqual(await service.clearBackups(), { deletedCount: 0 });
|
||||
assert.equal(syncCalls, 2);
|
||||
assert.equal(closeCalls, 2);
|
||||
} finally {
|
||||
fs.rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
@@ -1374,6 +1374,8 @@ function createPreloadApi(ctx) {
|
||||
ipcRenderer.invoke("netcatty:vaultBackups:read", payload),
|
||||
trimVaultBackups: (payload) =>
|
||||
ipcRenderer.invoke("netcatty:vaultBackups:trim", payload),
|
||||
clearVaultBackups: () =>
|
||||
ipcRenderer.invoke("netcatty:vaultBackups:clear"),
|
||||
openVaultBackupDir: () =>
|
||||
ipcRenderer.invoke("netcatty:vaultBackups:openDir"),
|
||||
// Subscribe to cross-window "backups changed" events emitted by the
|
||||
|
||||
@@ -296,8 +296,6 @@ export const STORAGE_KEY_LOCAL_SHELL_SIDE_PANEL_AUTO_OPEN_TAB = 'netcatty_local_
|
||||
export const STORAGE_KEY_WORKSPACE_FOCUS_SIDEBAR_WIDTH = 'netcatty_workspace_focus_sidebar_width';
|
||||
export const STORAGE_KEY_TERMINAL_HOST_TREE_WIDTH = 'netcatty_terminal_host_tree_width_v1';
|
||||
export const STORAGE_KEY_TERMINAL_HOST_TREE_COLLAPSED = 'netcatty_terminal_host_tree_collapsed_v1';
|
||||
/** Terminal scripts side panel library: 'list' vs 'stacked' view mode. */
|
||||
export const STORAGE_KEY_SCRIPTS_SIDE_PANEL_VIEW = 'netcatty:scripts:sidePanelView';
|
||||
export const STORAGE_KEY_TERMINAL_COMPOSE_BAR_OPEN = 'netcatty_terminal_compose_bar_open_v1';
|
||||
/** Opt-in "broadcast without password protection" switch (terminal compose bar, issue #3488). */
|
||||
export const STORAGE_KEY_TERMINAL_BROADCAST_PASSWORD_BYPASS = 'netcatty_terminal_broadcast_password_bypass_v1';
|
||||
|
||||
@@ -46,7 +46,17 @@ diff --git a/node_modules/ssh2/lib/agent.js b/node_modules/ssh2/lib/agent.js
|
||||
|
||||
const req = new AgentInboundRequest(msgType, ctx);
|
||||
this[SYM_REQS].push(req);
|
||||
@@ -781,7 +788,11 @@
|
||||
@@ -732,6 +739,9 @@
|
||||
default: {
|
||||
const req = new AgentInboundRequest(msgType);
|
||||
this[SYM_REQS].push(req);
|
||||
+ // Unknown extensions can carry data. Consume their entire frame
|
||||
+ // before reading the next request on this connection.
|
||||
+ p = 4 + this[SYM_MSGLEN];
|
||||
this.failureReply(req);
|
||||
}
|
||||
}
|
||||
@@ -781,7 +791,11 @@
|
||||
if (pubKey instanceof Error)
|
||||
throw new Error('Invalid public key argument');
|
||||
|
||||
@@ -59,7 +69,7 @@ diff --git a/node_modules/ssh2/lib/agent.js b/node_modules/ssh2/lib/agent.js
|
||||
switch (options.hash) {
|
||||
case 'sha256':
|
||||
flags = SSH_AGENT_RSA_SHA2_256;
|
||||
@@ -992,19 +1003,24 @@
|
||||
@@ -992,19 +1006,24 @@
|
||||
let p = 0;
|
||||
const sigFormat = req.getContext();
|
||||
const sigFormatLen = Buffer.byteLength(sigFormat);
|
||||
|
||||
@@ -8,7 +8,8 @@ const assert = require("node:assert/strict");
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
const net = require("node:net");
|
||||
const { execFileSync, spawn } = require("node:child_process");
|
||||
const { execFileSync, execFile, spawn } = require("node:child_process");
|
||||
const execFileAsync = require("node:util").promisify(execFile);
|
||||
const { once } = require("node:events");
|
||||
const { Client } = require("ssh2");
|
||||
const { generateFidoSshKeyPair } = require("../electron/bridges/fidoSshKeygen.cjs");
|
||||
@@ -114,6 +115,8 @@ test("software FIDO authenticator authenticates against real OpenSSH", {
|
||||
execFileSync("ssh-keygen", ["-s", path.join(dir, "host"), "-I", "fido-sim",
|
||||
"-n", username, "-V", "-1m:+5m", "-O", "verify-required", `${keyPath}.pub`]);
|
||||
const certificate = fs.readFileSync(`${keyPath}-cert.pub`, "utf8").trim();
|
||||
// A selected SK handle remains usable without its optional .pub file.
|
||||
fs.unlinkSync(`${keyPath}.pub`);
|
||||
// Probe must use the same %r expansion as ssh-add, before deciding agent auth.
|
||||
const options = await enhanceAuthOptionsForFido({
|
||||
hostname: "127.0.0.1", port, username, useSshAgent: false,
|
||||
@@ -169,6 +172,39 @@ test("software FIDO authenticator authenticates against real OpenSSH", {
|
||||
assert.equal(await exec(connection, "printf multi-window-ok"), "multi-window-ok");
|
||||
}));
|
||||
assert.deepEqual(new Set(routedPrompts.slice(beforeRouting)), new Set([sender.id, otherSender.id]));
|
||||
const nativeAgents = await Promise.all([sender, otherSender].map(owner => prepareSystemSshAgentForAuth({
|
||||
...options, nativeFidoAgent: true, resolveWebContents: () => owner,
|
||||
}, "[NativeFidoWindow]")));
|
||||
try {
|
||||
const beforeNative = routedPrompts.length;
|
||||
const results = await Promise.all(nativeAgents.map(nativeAgent => execFileAsync("ssh", [
|
||||
"-o", `IdentityAgent=${nativeAgent._netcattyAgentSocket}`,
|
||||
"-o", "IdentitiesOnly=yes", "-o", "BatchMode=yes", "-o", "CertificateFile=none",
|
||||
"-o", "StrictHostKeyChecking=no", "-o", "UserKnownHostsFile=/dev/null",
|
||||
"-i", keyPath, "-p", String(port), `${username}@127.0.0.1`, "printf native-fido-ok",
|
||||
], { timeout: 15000 })));
|
||||
for (const result of results) assert.equal(result.stdout, "native-fido-ok");
|
||||
assert.deepEqual(new Set(routedPrompts.slice(beforeNative)), new Set([sender.id, otherSender.id]));
|
||||
} finally { nativeAgents.forEach(nativeAgent => nativeAgent._releaseNetcattyFidoAgent?.()); }
|
||||
// A second certificate for an owned bare key needs its own hold.
|
||||
// Closing it must remove that certificate and retain the first.
|
||||
const extraPubPath = path.join(dir, `${type}-extra.pub`);
|
||||
fs.writeFileSync(extraPubPath, generated.publicKey);
|
||||
execFileSync("ssh-keygen", ["-s", path.join(dir, "host"), "-I", "fido-extra", "-z", "2",
|
||||
"-n", username, "-V", "-1m:+5m", "-O", "verify-required", extraPubPath]);
|
||||
const extraCertificate = fs.readFileSync(extraPubPath.replace(/\.pub$/, "-cert.pub"), "utf8").trim();
|
||||
const extraAgent = await prepareSystemSshAgentForAuth({
|
||||
...options, privateKey: generated.privateKey, identityFilePaths: [], certificate: extraCertificate,
|
||||
}, "[ExtraFidoCertificate]");
|
||||
const sharedSocket = require("../electron/bridges/fidoAgentManager.cjs").getActiveFidoAgentSocket();
|
||||
extraAgent._releaseNetcattyFidoAgent();
|
||||
await require("../electron/bridges/systemSshAgent.cjs").withAgentIdentityLock(sharedSocket, async () => {});
|
||||
const remaining = (await execFileAsync("ssh-add", ["-L"], {
|
||||
env: { ...process.env, SSH_AUTH_SOCK: sharedSocket },
|
||||
})).stdout;
|
||||
assert.ok(remaining.includes(generated.publicKey.split(" ")[1]), "bare identity remains in use");
|
||||
assert.ok(remaining.includes(certificate.split(" ")[1]), "other session's certificate remains in use");
|
||||
assert.ok(!remaining.includes(extraCertificate.split(" ")[1]), "released certificate is removed");
|
||||
} finally {
|
||||
pair.forEach((connection) => connection.end());
|
||||
otherAgent._releaseNetcattyFidoAgent?.();
|
||||
@@ -180,7 +216,7 @@ test("software FIDO authenticator authenticates against real OpenSSH", {
|
||||
fs.writeFileSync(authorizedKeys,
|
||||
`cert-authority,verify-required,principals="${username}" ${fs.readFileSync(path.join(dir, "host.pub"), "utf8")}`);
|
||||
const certAgent = await prepareSystemSshAgentForAuth({
|
||||
...options, certificate,
|
||||
...options, certificate, nativeFidoAgent: true,
|
||||
...(type === "ECDSA-SK" ? { privateKey: generated.privateKey, identityFilePaths: [] } : {}),
|
||||
}, "[FidoCertificateSimulation]");
|
||||
const certClient = new Client();
|
||||
@@ -188,6 +224,13 @@ test("software FIDO authenticator authenticates against real OpenSSH", {
|
||||
certClient.connect({ host: options.hostname, port, username, agent: certAgent, readyTimeout: 15000 });
|
||||
await once(certClient, "ready");
|
||||
assert.equal(await exec(certClient, "printf fido-cert-ok"), "fido-cert-ok");
|
||||
const nativeCertificate = await execFileAsync("ssh", [
|
||||
"-o", `IdentityAgent=${certAgent._netcattyAgentSocket}`, "-o", "IdentitiesOnly=yes", "-o", "BatchMode=yes",
|
||||
"-o", `CertificateFile=${keyPath}-cert.pub`, "-o", "StrictHostKeyChecking=no",
|
||||
"-o", "UserKnownHostsFile=/dev/null", "-i", keyPath, "-p", String(port),
|
||||
`${username}@127.0.0.1`, "printf native-cert-ok",
|
||||
], { timeout: 15000 });
|
||||
assert.equal(nativeCertificate.stdout, "native-cert-ok");
|
||||
} finally {
|
||||
certClient.end();
|
||||
certAgent._releaseNetcattyFidoAgent?.();
|
||||
|
||||
@@ -11,12 +11,14 @@ const { execFile } = require("node:child_process");
|
||||
const { promisify } = require("node:util");
|
||||
const { once } = require("node:events");
|
||||
const { Client, Server, utils } = require("ssh2");
|
||||
const { createAgent } = require("ssh2/lib/agent.js");
|
||||
const tempDirBridge = require("../electron/bridges/tempDirBridge.cjs");
|
||||
const askpass = require("../electron/bridges/fidoAskpass.cjs");
|
||||
const prompts = require("../electron/bridges/fidoPromptHandler.cjs");
|
||||
const { generateFidoSshKeyPair } = require("../electron/bridges/fidoSshKeygen.cjs");
|
||||
const { enhanceAuthOptionsForFido, prepareSystemSshAgentForAuth } = require("../electron/bridges/sshAuthHelper.cjs");
|
||||
const { shutdownFidoAgentSubsystem } = require("../electron/bridges/fidoAgentManager.cjs");
|
||||
const manager = require("../electron/bridges/fidoAgentManager.cjs");
|
||||
const { attachFidoAgentRelease } = require("../electron/bridges/attachFidoAgentRelease.cjs");
|
||||
const run = promisify(execFile);
|
||||
const pause = ms => new Promise(resolve => setTimeout(resolve, ms));
|
||||
@@ -34,6 +36,11 @@ test("Windows security-key integration with real user-mode OpenSSH", {
|
||||
timeout: 180000,
|
||||
}, async t => {
|
||||
const dir = fs.mkdtempSync(path.join(tempDirBridge.getTempDir(), "fw-"));
|
||||
const originalGetTempDir = tempDirBridge.getTempDir;
|
||||
const originalAcquire = manager.acquireFidoAgent;
|
||||
const spacedTemp = path.join(dir, "profile space");
|
||||
fs.mkdirSync(spacedTemp);
|
||||
tempDirBridge.getTempDir = () => spacedTemp;
|
||||
const control = path.join(dir, "presence");
|
||||
const saved = Object.fromEntries(["NETCATTY_SSH_AGENT_PATH", "SSH_SK_PROVIDER", "NETCATTY_FIDO_SIM_CONTROL"].map(key => [key, process.env[key]]));
|
||||
const seen = [];
|
||||
@@ -90,15 +97,18 @@ test("Windows security-key integration with real user-mode OpenSSH", {
|
||||
const session = accept();
|
||||
session.on("exec", acceptExec => {
|
||||
const stream = acceptExec();
|
||||
stream.end("windows-fido-ok");
|
||||
stream.write("windows-fido-ok");
|
||||
stream.exit(0);
|
||||
stream.end();
|
||||
});
|
||||
}));
|
||||
});
|
||||
|
||||
async function connect(generated, password) {
|
||||
async function connect(generated, password, nativeAgent = false) {
|
||||
const options = await enhanceAuthOptionsForFido({
|
||||
hostname: "127.0.0.1", port: server.address().port, username: "netcatty",
|
||||
privateKey: generated.privateKey, identitiesOnly: true, keyLabel: "Windows test key", password,
|
||||
nativeFidoAgent: nativeAgent,
|
||||
}, sender);
|
||||
const agent = await prepareSystemSshAgentForAuth(options, "[WindowsFido]");
|
||||
assert.ok(agent, "production auth preparation must return an agent");
|
||||
@@ -113,7 +123,8 @@ test("Windows security-key integration with real user-mode OpenSSH", {
|
||||
});
|
||||
client.once("close", () => resolve({ error: new Error("Connection closed") }));
|
||||
});
|
||||
client.connect({ host: "127.0.0.1", port: server.address().port, username: "netcatty", agent,
|
||||
client.connect({ host: "127.0.0.1", port: server.address().port, username: "netcatty",
|
||||
agent: nativeAgent ? createAgent(agent._netcattyAgentSocket) : agent,
|
||||
password, authHandler: password ? ["agent", "password"] : ["agent"], readyTimeout: 15000 });
|
||||
return { client, outcome, close() { client.end(); agent._releaseNetcattyFidoAgent?.(); } };
|
||||
}
|
||||
@@ -122,6 +133,14 @@ test("Windows security-key integration with real user-mode OpenSSH", {
|
||||
assert.ok(process.env.NETCATTY_WINDOWS_SSH_AGENT, "Windows OpenSSH agent path is required");
|
||||
assert.ok(process.env.NETCATTY_WINDOWS_SSH_KEYGEN, "Windows OpenSSH keygen path is required");
|
||||
process.env.NETCATTY_SSH_AGENT_PATH = process.env.NETCATTY_WINDOWS_SSH_AGENT;
|
||||
manager.acquireFidoAgent = options => originalAcquire({ ...options, execFile: async (binary, args, options) => {
|
||||
try { return await run(binary, args, options); }
|
||||
catch (error) {
|
||||
// Agent startup has no secret input; surface binding errors in CI.
|
||||
console.log("[WindowsFido] agent startup rejected", args, error.stderr || error.message);
|
||||
throw error;
|
||||
}
|
||||
} });
|
||||
process.env.SSH_SK_PROVIDER = "/usr/lib/netcatty-fido-test-provider.dll";
|
||||
process.env.NETCATTY_FIDO_SIM_CONTROL = control.replace(/\\/g, "/");
|
||||
server.listen(0, "127.0.0.1");
|
||||
@@ -156,7 +175,7 @@ test("Windows security-key integration with real user-mode OpenSSH", {
|
||||
const before = seen.length;
|
||||
fs.rmSync(`${control}.waiting`, { force: true });
|
||||
fs.writeFileSync(control, "waiting");
|
||||
const connection = await connect(generated);
|
||||
const connection = await connect(generated, undefined, true);
|
||||
let settled = false;
|
||||
connection.outcome.then(() => { settled = true; });
|
||||
try {
|
||||
@@ -176,6 +195,33 @@ test("Windows security-key integration with real user-mode OpenSSH", {
|
||||
} finally { connection.close(); }
|
||||
});
|
||||
|
||||
for (const runtime of ["MSYS", "Win32"]) {
|
||||
await t.test(`${type}: real ${runtime} OpenSSH logs in through its native socket`, async () => {
|
||||
const sshPath = runtime === "MSYS"
|
||||
? path.join(path.dirname(process.env.NETCATTY_WINDOWS_SSH_AGENT), "ssh.exe")
|
||||
: path.join(process.env.SystemRoot, "System32", "OpenSSH", "ssh.exe");
|
||||
assert.ok(fs.existsSync(sshPath), `${runtime} SSH client must be installed`);
|
||||
const before = seen.length;
|
||||
fs.writeFileSync(control, "touch");
|
||||
const options = await enhanceAuthOptionsForFido({
|
||||
privateKey: generated.privateKey, identitiesOnly: true,
|
||||
nativeFidoAgent: true, nativeSshPath: sshPath,
|
||||
}, sender);
|
||||
const agent = await prepareSystemSshAgentForAuth(options, "[WindowsNativeFido]");
|
||||
try {
|
||||
const identityAgent = agent._netcattyAgentSocket;
|
||||
assert.equal(identityAgent.startsWith("\\\\.\\pipe"), runtime === "Win32");
|
||||
const result = await run(sshPath, ["-F", "none", "-p", String(server.address().port),
|
||||
"-o", "StrictHostKeyChecking=no", "-o", "UserKnownHostsFile=NUL",
|
||||
"-o", "PreferredAuthentications=publickey", "-o", "BatchMode=yes",
|
||||
"-o", `IdentityAgent=${JSON.stringify(identityAgent)}`, "netcatty@127.0.0.1", "probe"],
|
||||
{ env: { ...process.env, SSH_AUTH_SOCK: identityAgent }, timeout: 15000, windowsHide: true });
|
||||
assert.equal(result.stdout, "windows-fido-ok");
|
||||
assert.ok(seen.slice(before).some(request => request.kind === "pin"));
|
||||
} finally { agent._releaseNetcattyFidoAgent?.(); }
|
||||
});
|
||||
}
|
||||
|
||||
for (const fallback of [false, true]) {
|
||||
await t.test(`${type}: missing device ${fallback ? "allows explicit password fallback" : "cancel never uses saved password"}`, async () => {
|
||||
const before = seen.length;
|
||||
@@ -207,13 +253,15 @@ test("Windows security-key integration with real user-mode OpenSSH", {
|
||||
} finally { connection.close(); responsePin = "123456"; }
|
||||
});
|
||||
}
|
||||
assert.equal(acceptedPublicKeys, 2, "server must verify both actual SK signatures");
|
||||
assert.equal(acceptedPublicKeys, 6, "server must verify both SK algorithms through ssh2, MSYS SSH, and Win32 SSH");
|
||||
assert.equal(acceptedPasswords, 2, "only explicit fallback may use a password");
|
||||
} finally {
|
||||
for (const client of clients) client.end();
|
||||
await new Promise(resolve => server.close(resolve));
|
||||
shutdownFidoAgentSubsystem();
|
||||
askpass.shutdownFidoAskpass();
|
||||
tempDirBridge.getTempDir = originalGetTempDir;
|
||||
manager.acquireFidoAgent = originalAcquire;
|
||||
for (const [key, value] of Object.entries(saved)) {
|
||||
if (value === undefined) delete process.env[key]; else process.env[key] = value;
|
||||
}
|
||||
|
||||
+1
@@ -268,6 +268,7 @@ declare global {
|
||||
payload: import('./domain/sync').SyncPayload;
|
||||
}>;
|
||||
trimVaultBackups?(payload: { maxCount: number }): Promise<{ deletedCount: number; keptCount: number }>;
|
||||
clearVaultBackups?(): Promise<{ deletedCount: number }>;
|
||||
openVaultBackupDir?(): Promise<{ success: boolean; path: string }>;
|
||||
// Subscribe to main-process-driven "vault backups changed" events.
|
||||
// Returns an unsubscribe callback. Undefined in non-Electron builds.
|
||||
|
||||
Reference in new issue
Block a user