From 22e803886b1acef2b680e57deb8c831789de84c8 Mon Sep 17 00:00:00 2001 From: Pablo Duce <66517318+Paduce@users.noreply.github.com> Date: Tue, 14 Jul 2026 13:19:44 +0200 Subject: [PATCH 1/2] feat: open projects directly over SSH --- .changeset/add-remote-project-serve.md | 5 + README.md | 9 + .../AssetPreview.text-dispatch.dom.test.tsx | 35 +- packages/app/src/components/AssetPreview.tsx | 36 +- .../components/CommandPalette.dom.test.tsx | 55 + .../app/src/components/CommandPalette.tsx | 23 +- .../src/components/FileSidebar.dom.test.tsx | 36 +- packages/app/src/components/FileSidebar.tsx | 4 +- .../FileTree.duplicate.dom.test.tsx | 69 + packages/app/src/components/FileTree.tsx | 31 +- .../src/components/NavigatorApp.dom.test.tsx | 49 +- packages/app/src/components/NavigatorApp.tsx | 76 +- .../components/ProjectSwitcher.dom.test.tsx | 67 + .../app/src/components/ProjectSwitcher.tsx | 114 +- .../app/src/components/RecentProjectsMenu.tsx | 18 +- .../RemoteProjectDialog.dom.test.tsx | 449 +++ .../components/RemoteProjectDialog.test.ts | 80 + .../src/components/RemoteProjectDialog.tsx | 810 ++++++ .../src/components/TerminalPanel.dom.test.tsx | 110 +- packages/app/src/components/TerminalPanel.tsx | 45 +- .../src/components/TerminalRefusalNotice.tsx | 6 +- .../handoff/useInstalledAgents.test.ts | 28 + .../components/handoff/useInstalledAgents.ts | 18 +- .../components/project-switcher-recents.ts | 5 + .../settings/SettingsDialogShell.dom.test.tsx | 28 + .../settings/SettingsDialogShell.tsx | 7 +- .../settings/TerminalSection.dom.test.tsx | 46 + .../components/settings/TerminalSection.tsx | 70 +- packages/app/src/components/skill-actions.tsx | 2 +- .../components/terminal-link-provider.test.ts | 11 + .../src/components/terminal-link-provider.ts | 7 + .../src/hooks/use-editor-footer-identity.ts | 5 +- packages/app/src/lib/desktop-bridge-types.ts | 24 +- .../src/lib/handoff/skill-installer.test.ts | 30 + .../app/src/lib/handoff/skill-installer.ts | 22 +- .../src/lib/remote-project-display.test.ts | 26 + .../app/src/lib/remote-project-display.ts | 15 + .../app/src/lib/restart-collab-server.test.ts | 37 +- packages/app/src/lib/restart-collab-server.ts | 13 +- packages/app/src/lib/seed-client.test.ts | 35 + packages/app/src/lib/seed-client.ts | 18 +- packages/app/src/lib/use-workspace.test.ts | 20 + packages/app/src/lib/use-workspace.ts | 7 + packages/app/src/locales/en/messages.json | 92 + packages/app/src/locales/en/messages.po | 269 ++ packages/app/src/locales/pseudo/messages.json | 92 + packages/app/src/locales/pseudo/messages.po | 269 ++ .../tests/stress/fixtures/handoff-mocks.ts | 13 + packages/cli/package.json | 2 +- packages/cli/src/commands/remote.test.ts | 583 ++++ packages/cli/src/commands/remote.ts | 443 +++ packages/cli/src/commands/start.test.ts | 29 +- packages/cli/src/commands/start.ts | 34 +- packages/cli/src/project-anchor.test.ts | 8 + packages/cli/src/remote-companion.ts | 98 + .../cli/src/remote-project-bootstrap.test.ts | 231 ++ packages/cli/src/remote-project-bootstrap.ts | 184 ++ packages/cli/tsdown.remote.config.ts | 47 + packages/core/src/desktop-bridge.ts | 26 +- packages/core/src/index.ts | 11 + packages/core/src/remote-project.test.ts | 45 + packages/core/src/remote-project.ts | 90 + packages/core/src/sharing/receive-flow.ts | 10 + .../src/main/boot-restore-decision.test.ts | 22 +- .../desktop/src/main/boot-restore-decision.ts | 13 + .../src/main/branch-info-proxy.test.ts | 16 + .../desktop/src/main/branch-info-proxy.ts | 4 + packages/desktop/src/main/index.ts | 1113 +++++++- packages/desktop/src/main/menu.ts | 8 +- .../src/main/navigator-trust-boundary.ts | 210 ++ packages/desktop/src/main/navigator-window.ts | 15 + .../src/main/remote-editor-trust-boundary.ts | 243 ++ .../main/remote-machine-open-guard.test.ts | 47 + .../src/main/remote-machine-open-guard.ts | 23 + .../src/main/remote-project-open.test.ts | 153 ++ .../desktop/src/main/remote-project-open.ts | 111 + .../src/main/remote-project-service.ts | 2407 +++++++++++++++++ .../main/remote-reconnect-coordinator.test.ts | 37 + .../src/main/remote-reconnect-coordinator.ts | 27 + .../src/main/remote-session-lifecycle.test.ts | 51 + .../src/main/remote-session-lifecycle.ts | 45 + packages/desktop/src/main/state-store.ts | 194 +- packages/desktop/src/main/terminal-manager.ts | 10 + .../src/main/terminal-window-registry.test.ts | 238 +- .../src/main/terminal-window-registry.ts | 181 +- .../desktop/src/main/terminal-window.test.ts | 93 +- packages/desktop/src/main/terminal-window.ts | 49 +- packages/desktop/src/main/window-manager.ts | 274 ++ packages/desktop/src/preload/index.ts | 66 + .../desktop/src/shared/bridge-contract.ts | 27 +- packages/desktop/src/shared/ipc-channels.ts | 44 +- packages/desktop/src/utility/pty-host.ts | 17 +- .../ipc-channel-count-ratchet.test.ts | 12 +- packages/desktop/tests/main/menu.test.ts | 22 +- .../main/navigator-trust-boundary.test.ts | 280 ++ .../tests/main/navigator-window.test.ts | 33 +- .../main/remote-editor-trust-boundary.test.ts | 216 ++ .../tests/main/remote-project-service.test.ts | 1323 +++++++++ .../desktop/tests/main/state-store.test.ts | 197 ++ .../tests/main/terminal-manager.test.ts | 25 + .../terminal-window-attach-survival.test.ts | 14 +- .../tests/main/terminal-window-pty.test.ts | 9 +- .../desktop/tests/main/window-manager.test.ts | 515 ++++ .../desktop/tests/utility/pty-host.test.ts | 15 + packages/server/src/boot.ts | 2 + packages/server/src/file-watcher.test.ts | 29 +- packages/server/src/file-watcher.ts | 77 +- packages/server/src/server-factory.ts | 7 +- 108 files changed, 13349 insertions(+), 342 deletions(-) create mode 100644 .changeset/add-remote-project-serve.md create mode 100644 packages/app/src/components/RemoteProjectDialog.dom.test.tsx create mode 100644 packages/app/src/components/RemoteProjectDialog.test.ts create mode 100644 packages/app/src/components/RemoteProjectDialog.tsx create mode 100644 packages/app/src/lib/remote-project-display.test.ts create mode 100644 packages/app/src/lib/remote-project-display.ts create mode 100644 packages/app/src/lib/seed-client.test.ts create mode 100644 packages/cli/src/commands/remote.test.ts create mode 100644 packages/cli/src/commands/remote.ts create mode 100644 packages/cli/src/remote-companion.ts create mode 100644 packages/cli/src/remote-project-bootstrap.test.ts create mode 100644 packages/cli/src/remote-project-bootstrap.ts create mode 100644 packages/cli/tsdown.remote.config.ts create mode 100644 packages/core/src/remote-project.test.ts create mode 100644 packages/core/src/remote-project.ts create mode 100644 packages/desktop/src/main/navigator-trust-boundary.ts create mode 100644 packages/desktop/src/main/remote-editor-trust-boundary.ts create mode 100644 packages/desktop/src/main/remote-machine-open-guard.test.ts create mode 100644 packages/desktop/src/main/remote-machine-open-guard.ts create mode 100644 packages/desktop/src/main/remote-project-open.test.ts create mode 100644 packages/desktop/src/main/remote-project-open.ts create mode 100644 packages/desktop/src/main/remote-project-service.ts create mode 100644 packages/desktop/src/main/remote-reconnect-coordinator.test.ts create mode 100644 packages/desktop/src/main/remote-reconnect-coordinator.ts create mode 100644 packages/desktop/src/main/remote-session-lifecycle.test.ts create mode 100644 packages/desktop/src/main/remote-session-lifecycle.ts create mode 100644 packages/desktop/tests/main/navigator-trust-boundary.test.ts create mode 100644 packages/desktop/tests/main/remote-editor-trust-boundary.test.ts create mode 100644 packages/desktop/tests/main/remote-project-service.test.ts diff --git a/.changeset/add-remote-project-serve.md b/.changeset/add-remote-project-serve.md new file mode 100644 index 000000000..edbf1ea8c --- /dev/null +++ b/.changeset/add-remote-project-serve.md @@ -0,0 +1,5 @@ +--- +'@inkeep/open-knowledge': patch +--- + +Open projects from saved macOS or Linux SSH machines in the OpenKnowledge desktop app, including remote browsing, editing, search, and terminals over a loopback-only SSH connection. Desktop installs its matching remote support automatically in the SSH user's home directory with no global `ok` install, `sudo`, or PATH changes, shows saved remotes from the SSH machine box, and asks before initializing a selected folder. diff --git a/README.md b/README.md index ae967958d..465f34b7b 100644 --- a/README.md +++ b/README.md @@ -36,6 +36,7 @@ Highlights: - Full true **WYSIWYG** so that editing markdown files feels like editing a Google Doc or Notion page. - **macOS app** and **web UI** with file navigator, search, tabs, graph wiki link viewer, and more. +- **Remote projects over SSH** in the macOS app, with no separate web-editor tunnel to manage. - Integrated **side-by-side AI-editing** with **Claude, Codex, OpenCode, Pi and others**. Can be used with any harness/agent via MCP/CLI. - Out-of-the-box **MCP**, **skills**, and **agentic search** for LLM Wikis, second brains, and knowledge graphs. - No-code **Team sharing** and **Auto-sync** powered by git/GitHub under the hood. @@ -68,6 +69,14 @@ Either way, the app will walk you through installing the MCP and skills for agen Git/GitHub based sync and sharing can optionally be enabled. +### Remote projects over SSH + +The macOS app can open a project directly from a remote macOS or Linux machine with a POSIX-compatible login shell. The remote machine needs Node.js 24 or newer and Git 2.31.0 or newer. You do not need to install the `ok` CLI on the SSH machine: Desktop installs its matching, versioned remote support under the SSH user's `~/.ok/remote` directory without `sudo` or PATH changes. OpenKnowledge inspects the selected folder before changing it; if it is not initialized, the app shows the exact folder and project files and asks you to confirm before creating `.ok/config.yml`, `.ok/.gitignore`, and `.okignore`. + +Make sure the machine is reachable non-interactively through your system SSH config or SSH agent. In the app's project Navigator, choose **Open over SSH**, add the SSH host or config alias, and select the remote folder. + +OpenKnowledge starts its bundled support process on the remote loopback interface and reaches it through a local SSH tunnel. The project server is not exposed on the remote machine's network, and the app does not store passwords or private keys. + Docs for general usage: . ## Contributions diff --git a/packages/app/src/components/AssetPreview.text-dispatch.dom.test.tsx b/packages/app/src/components/AssetPreview.text-dispatch.dom.test.tsx index 13715e71a..ad118e75d 100644 --- a/packages/app/src/components/AssetPreview.text-dispatch.dom.test.tsx +++ b/packages/app/src/components/AssetPreview.text-dispatch.dom.test.tsx @@ -21,7 +21,7 @@ * * Runs under `bun run test:dom` (jsdom substrate per precedent #43). */ -import { afterEach, describe, expect, mock, test } from 'bun:test'; +import { afterEach, beforeEach, describe, expect, mock, test } from 'bun:test'; import { cleanup, fireEvent, render } from '@testing-library/react'; // Stub dispatchAssetClick so the button's onClick does not attempt a @@ -46,9 +46,14 @@ mock.module('@/lib/config-provider', () => ({ const { AssetPreview } = await import('./AssetPreview.tsx'); describe('AssetPreview — text-viewer dispatch', () => { + beforeEach(() => { + Reflect.deleteProperty(window, 'okDesktop'); + }); + afterEach(() => { cleanup(); dispatchAssetClickStub.mockClear(); + Reflect.deleteProperty(window, 'okDesktop'); }); test('mediaKind=text on a json asset mounts TextViewer (not the fallback)', () => { @@ -101,6 +106,34 @@ describe('AssetPreview — text-viewer dispatch', () => { }); }); + test('remote assets keep the in-app text view and hide the local native-open action', () => { + Object.defineProperty(window, 'okDesktop', { + configurable: true, + value: { + config: { + remote: { + kind: 'ssh', + machineId: 'machine-1', + machineName: 'Build box', + path: '/srv/knowledge', + platform: 'linux', + pathSeparator: '/', + }, + apiOrigin: 'http://127.0.0.1:43123', + }, + }, + }); + + const { container } = render(); + expect(container.querySelector('[data-testid="asset-preview-open-as-text"]')).not.toBeNull(); + expect( + Array.from(container.querySelectorAll('button')).find((button) => + /open file/i.test(button.textContent ?? ''), + ), + ).toBeUndefined(); + expect(dispatchAssetClickStub).not.toHaveBeenCalled(); + }); + test('clicking "View as text" flips into the text branch', () => { const { container } = render(); const btn = container.querySelector( diff --git a/packages/app/src/components/AssetPreview.tsx b/packages/app/src/components/AssetPreview.tsx index 7542940bc..2266ab943 100644 --- a/packages/app/src/components/AssetPreview.tsx +++ b/packages/app/src/components/AssetPreview.tsx @@ -74,6 +74,8 @@ function AssetPreviewBody({ assetPath, mediaKind }: AssetPreviewProps) { // so the user can override an image / video / pdf / no-viewer asset // into the text-editor pane and back via the assetPath-keyed reset. const effectiveMediaKind: InlineAssetMediaKind | null = forceText ? 'text' : mediaKind; + const canOpenWithLocalApplication = + typeof window === 'undefined' || window.okDesktop?.config.remote == null; // PDFs render edge-to-edge via the bundled `` viewer — toolbar + // page-scroll fill the available height. `fillContainer` makes the @@ -176,22 +178,24 @@ function AssetPreviewBody({ assetPath, mediaKind }: AssetPreviewProps) { > View as text - + {canOpenWithLocalApplication ? ( + + ) : null} )} diff --git a/packages/app/src/components/CommandPalette.dom.test.tsx b/packages/app/src/components/CommandPalette.dom.test.tsx index 506370e84..3692abf35 100644 --- a/packages/app/src/components/CommandPalette.dom.test.tsx +++ b/packages/app/src/components/CommandPalette.dom.test.tsx @@ -347,6 +347,28 @@ describe('CommandPalette DOM behavior', () => { ); }); + test('uses the SSH server icon for a remote recent project', async () => { + const bridge = createBridge(); + bridge.project.listRecent = mock(() => + Promise.resolve([ + { + name: 'Remote Notes', + path: 'ssh:machine-1:%2Fsrv%2Fnotes', + lastOpenedAt: '2026-07-14T00:00:00.000Z', + remote: { + machineId: 'machine-1', + machineName: 'Build box', + path: '/srv/notes', + }, + }, + ]), + ); + + await renderPalette({ bridge }); + const row = await screen.findByTestId('command-palette-recent-ssh:machine-1:%2Fsrv%2Fnotes'); + expect(row.querySelector('svg.lucide-server')).not.toBeNull(); + }); + test('settings command is searchable by preferences/config, closes the palette, and routes through the canonical hash', async () => { const { onOpenChange } = await renderPalette({ bridge: null }); @@ -641,6 +663,39 @@ describe('CommandPalette DOM behavior', () => { }); expect(refreshWorktreesMock).toHaveBeenCalled(); }); + + test('does not expose local worktrees from an SSH-backed project window', async () => { + worktreeModelMock = { + mainRoot: '/projects/current', + currentBranch: 'main', + entries: [ + { + branch: 'local-only', + worktreePath: '/projects/current/.ok/worktrees/local-only', + isCurrent: false, + isMain: false, + locked: false, + }, + ], + }; + const bridge = createBridge(); + Object.assign(bridge.config, { + projectPath: 'ssh:machine-1:%2Fsrv%2Fknowledge', + remote: { + kind: 'ssh', + machineId: 'machine-1', + machineName: 'Build box', + path: '/srv/knowledge', + platform: 'linux', + pathSeparator: '/', + }, + }); + + await renderPalette({ bridge }); + + expect(screen.queryByTestId('command-palette-worktree-local-only')).toBeNull(); + expect(screen.queryByRole('heading', { name: 'Worktrees' })).toBeNull(); + }); }); describe('NavigationItem path subtitle', () => { diff --git a/packages/app/src/components/CommandPalette.tsx b/packages/app/src/components/CommandPalette.tsx index c08685474..47583e2d9 100644 --- a/packages/app/src/components/CommandPalette.tsx +++ b/packages/app/src/components/CommandPalette.tsx @@ -24,6 +24,7 @@ import { Network, Package, Plus, + Server, Settings, Sparkles, } from 'lucide-react'; @@ -97,7 +98,7 @@ import { cn } from '@/lib/utils.ts'; import { refreshWorktrees } from '@/lib/worktree-store'; import { buildHandoffInput, useHandoffDispatch } from './handoff/useHandoffDispatch'; import { useInstalledAgents } from './handoff/useInstalledAgents'; -import { basenameOf } from './project-switcher-recents'; +import { basenameOf, recentProjectDisplayPath } from './project-switcher-recents'; const COMMAND_PALETTE_SEARCH_TIMEOUT_MS = 3000; // Re-poll cadence while the server reports the search index is still warming @@ -439,7 +440,7 @@ export function CommandPalette({ bridge = null, open, onOpenChange }: CommandPal // in switching to yourself. `null` off-desktop / until the first fetch lands. const worktreeModel = useWorktrees(); const switchableWorktrees = - bridge && worktreeModel + bridge && bridge.config.remote == null && worktreeModel ? worktreeModel.entries.filter((entry) => entry.branch !== null && !entry.isCurrent) : []; const initialCreateDir = resolveCreateInitialDir(activeTarget, activeDocName); @@ -856,7 +857,9 @@ export function CommandPalette({ bridge = null, open, onOpenChange }: CommandPal switchableProjects.length > 0 && (trimmedDeferredQuery === '' || switchableProjects.some((row) => - matchesCommandQuery(`${row.name} ${row.path}`, deferredQuery, ['open recent project']), + matchesCommandQuery(`${row.name} ${recentProjectDisplayPath(row)}`, deferredQuery, [ + 'open recent project', + ]), )); const matchedWorktrees = switchableWorktrees.filter( (entry) => @@ -1568,9 +1571,11 @@ export function CommandPalette({ bridge = null, open, onOpenChange }: CommandPal {switchableProjects .filter((row) => - matchesCommandQuery(`${row.name} ${row.path}`, deferredQuery, [ - 'open recent project', - ]), + matchesCommandQuery( + `${row.name} ${recentProjectDisplayPath(row)}`, + deferredQuery, + ['open recent project'], + ), ) .slice(0, 10) .map((row) => { @@ -1579,13 +1584,13 @@ export function CommandPalette({ bridge = null, open, onOpenChange }: CommandPal // a branch; every project uses the same plain folder. The // base-project note names the repo a worktree belongs to // (e.g. "worktree of pnw-fishing"). - const RowIcon = isWorktree ? GitBranch : Folder; + const RowIcon = isWorktree ? GitBranch : row.remote ? Server : Folder; const worktreeOf = isWorktree && row.mainRoot !== undefined ? basenameOf(row.mainRoot) : null; return ( runAction( @@ -1610,7 +1615,7 @@ export function CommandPalette({ bridge = null, open, onOpenChange }: CommandPal ) : null} - {row.path} + {recentProjectDisplayPath(row)} {row.missing ? ( <> {' '} diff --git a/packages/app/src/components/FileSidebar.dom.test.tsx b/packages/app/src/components/FileSidebar.dom.test.tsx index 03d547043..88a11a73d 100644 --- a/packages/app/src/components/FileSidebar.dom.test.tsx +++ b/packages/app/src/components/FileSidebar.dom.test.tsx @@ -103,24 +103,43 @@ const projectLocalPatch = mock((_patch: unknown) => projectPatchResult); const showItemInFolderMock = mock((_path: string) => Promise.resolve()); const notifyViewMenuStateChangedMock = mock((_snapshot: unknown) => {}); const onOpenSearch = mock(() => {}); +let menuActionListener: ((action: string) => void) | null = null; function setFolderState(next: FolderState) { folderState = next; for (const listener of treeListeners) listener(); } -function installBridge() { +function installBridge({ remote = false }: { remote?: boolean } = {}) { Object.defineProperty(window, 'okDesktop', { configurable: true, value: { platform: 'darwin', + config: { + projectPath: remote ? 'ssh:machine-1:%2Fsrv%2Fwiki' : '/tmp/open-knowledge', + remote: remote + ? { + machineId: 'machine-1', + machineLabel: 'Build box', + projectPath: '/srv/wiki', + } + : undefined, + }, editor: { notifyViewMenuStateChanged: notifyViewMenuStateChangedMock, }, + project: { + listRecent: () => Promise.resolve([{ path: '/tmp/another-project' }]), + }, shell: { showItemInFolder: showItemInFolderMock, }, - onMenuAction: () => () => {}, + onMenuAction: (listener: (action: string) => void) => { + menuActionListener = listener; + return () => { + if (menuActionListener === listener) menuActionListener = null; + }; + }, }, }); } @@ -442,6 +461,7 @@ describe('FileSidebar runtime behavior', () => { toastSuccesses = []; toastErrors = []; pillRenderErrors = []; + menuActionListener = null; treeListeners.clear(); for (const fn of [ treeCalls.collapseAll, @@ -782,6 +802,18 @@ describe('FileSidebar runtime behavior', () => { }); }); + test('SSH projects suppress Finder-only actions from renderer and native menus', async () => { + installBridge({ remote: true }); + await renderSidebar(); + + expect(screen.queryByTestId('empty-space-menu-reveal-in-finder')).toBeNull(); + expect(screen.getByTestId('empty-space-menu-copy-full-path')).toBeTruthy(); + await waitFor(() => expect(menuActionListener).not.toBeNull()); + + act(() => menuActionListener?.('reveal-in-finder')); + expect(showItemInFolderMock).not.toHaveBeenCalled(); + }); + test('empty-space visibility toggles carry full-form labels and patch their own leaves', async () => { mergedConfig = { appearance: { sidebar: { showOnlyMarkdownFiles: true } } }; await renderSidebar(); diff --git a/packages/app/src/components/FileSidebar.tsx b/packages/app/src/components/FileSidebar.tsx index c558f43e5..11a470332 100644 --- a/packages/app/src/components/FileSidebar.tsx +++ b/packages/app/src/components/FileSidebar.tsx @@ -576,7 +576,7 @@ function FileSidebarInner({ onOpenSearch }: FileSidebarProps) { return; } case 'reveal-in-finder': { - if (!bridge || !workspace) return; + if (!bridge || !workspace || bridge.config.remote) return; const absPath = resolveActiveTargetAbsPath(activeTarget, workspace); void bridge.shell.showItemInFolder(absPath); return; @@ -1215,7 +1215,7 @@ function FileSidebarInner({ onOpenSearch }: FileSidebarProps) { New folder - {bridge ? ( + {bridge && !bridge.config.remote ? ( {}); const closeAndClearForRenameMock = mock(async () => {}); const remapTabsForRenameMock = mock(() => {}); const dispatchHandoffMock = mock(async () => ({ ok: true as const })); +const trashItemMock = mock(async (_path: string) => ({ ok: true as const })); const DOCUMENTS: FileEntry[] = [ { @@ -225,9 +226,32 @@ let deleteConfirmationProps: { itemName?: string; customTitle?: string; customDescription?: string; + customDetail?: string; + customConfirmLabel?: string; + customConfirmLabelBusy?: string; onDelete?: () => void | Promise; } | null = null; +function installRemoteBridge() { + Object.defineProperty(window, 'okDesktop', { + configurable: true, + value: { + platform: 'darwin', + config: { + remote: { + machineId: 'machine-1', + machineLabel: 'Build box', + projectPath: '/srv/wiki', + }, + }, + shell: { + showItemInFolder: () => Promise.resolve(), + trashItem: trashItemMock, + }, + }, + }); +} + function jsonResponse(body: unknown, status = 200): Response { return new Response(JSON.stringify(body), { status, @@ -391,6 +415,9 @@ mock.module('@/components/DeleteConfirmationDialog', () => ({ itemName?: string; customTitle?: string; customDescription?: string; + customDetail?: string; + customConfirmLabel?: string; + customConfirmLabelBusy?: string; onDelete?: () => void | Promise; }) => { deleteConfirmationProps = props; @@ -486,11 +513,20 @@ describe('FileTree duplicate action runtime behavior', () => { addPageMock.mockClear(); openTargetMock.mockClear(); notifySidebarFileSelectedMock.mockClear(); + trashItemMock.mockClear(); + Object.defineProperty(window, 'okDesktop', { + configurable: true, + value: undefined, + }); consoleWarnSpy = spyOn(console, 'warn').mockImplementation(() => {}); }); afterEach(() => { cleanup(); + Object.defineProperty(window, 'okDesktop', { + configurable: true, + value: undefined, + }); consoleWarnSpy.mockRestore(); }); @@ -1007,6 +1043,39 @@ describe('FileTree duplicate action runtime behavior', () => { expect(deleteConfirmationProps?.customTitle).toBeUndefined(); }); + test('SSH projects confirm an irreversible delete and never invoke the local Trash', async () => { + installRemoteBridge(); + renderFileTree(); + await screen.findByRole('menuitem', { name: /duplicate/i }); + + model.focusedPath = 'notes/source.mdx'; + model.selectedPaths = []; + screen.getByTestId('tree-focus-target').focus(); + fireEvent.keyDown(document, { key: 'Delete' }); + + await screen.findByTestId('delete-confirmation-dialog'); + expect(deleteConfirmationProps).toMatchObject({ + customTitle: "Permanently delete 'source.mdx'?", + customDescription: '', + customDetail: + 'Remote files are deleted immediately. They are not moved to Trash, and this action cannot be undone.', + customConfirmLabel: 'Delete permanently', + customConfirmLabelBusy: 'Deleting', + }); + + fetchCalls = []; + await act(async () => { + await deleteConfirmationProps?.onDelete?.(); + }); + + await waitFor(() => expect(deletePathCalls()).toHaveLength(1)); + expect(JSON.parse(String(deletePathCalls()[0]?.init?.body))).toEqual({ + kind: 'file', + path: 'notes/source', + }); + expect(trashItemMock).not.toHaveBeenCalled(); + }); + test('Delete does not open confirmation while another file-tree action is busy', async () => { let releaseDuplicate: () => void = () => {}; duplicateGate = new Promise((resolve) => { diff --git a/packages/app/src/components/FileTree.tsx b/packages/app/src/components/FileTree.tsx index 2c6f62a57..0205622c0 100644 --- a/packages/app/src/components/FileTree.tsx +++ b/packages/app/src/components/FileTree.tsx @@ -506,7 +506,7 @@ function RevealInFileManagerMenuItem({ }) { const { t } = useLingui(); const bridge = typeof window !== 'undefined' ? window.okDesktop : undefined; - if (!bridge) return null; + if (!bridge || bridge.config.remote) return null; const platform = bridge.platform; const label = revealInFileManagerLabel(platform); const hint = !workspace ? t`No workspace` : null; @@ -3943,7 +3943,7 @@ export function FileTree({ const bridge = typeof window !== 'undefined' ? window.okDesktop : undefined; try { - if (bridge && workspace) { + if (bridge && workspace && !bridge.config.remote) { // Electron path: 2-step Trash flow. const { trashed, failed } = await trashTargetsViaShell(deleteTargets, bridge, workspace); if (trashed.length > 0) { @@ -4467,8 +4467,31 @@ export function FileTree({ // Trash flow on Electron uses VSCode-verbatim copy; // web mode (no OS Trash) keeps today's hard-delete copy. {...(() => { - const variant: 'electron' | 'web' = - typeof window !== 'undefined' && window.okDesktop != null ? 'electron' : 'web'; + const bridge = typeof window !== 'undefined' ? window.okDesktop : undefined; + if (bridge?.config.remote) { + const listedTargets = + deleteRequest.targets.length > 1 ? deleteRequest.targets : null; + return { + customTitle: + deleteRequest.targets.length === 1 + ? t`Permanently delete '${trashTargetDisplayName(primaryDeleteTarget)}'?` + : t`Permanently delete selected items?`, + customDescription: '', + customDetail: t`Remote files are deleted immediately. They are not moved to Trash, and this action cannot be undone.`, + customConfirmLabel: t`Delete permanently`, + customConfirmLabelBusy: t`Deleting`, + children: listedTargets ? ( +
    + {listedTargets.map((target) => ( +
  • + {trashTargetDisplayName(target)} +
  • + ))} +
+ ) : null, + }; + } + const variant: 'electron' | 'web' = bridge ? 'electron' : 'web'; const copy = selectTrashConfirmCopy(variant, deleteRequest.targets); if (copy) { return { diff --git a/packages/app/src/components/NavigatorApp.dom.test.tsx b/packages/app/src/components/NavigatorApp.dom.test.tsx index 2d3e27d90..9bf4726d0 100644 --- a/packages/app/src/components/NavigatorApp.dom.test.tsx +++ b/packages/app/src/components/NavigatorApp.dom.test.tsx @@ -10,6 +10,7 @@ let cloneDialogProps: Array<{ open: boolean; onCloneComplete: (payload: { dir: string }) => void; }> = []; +let remoteDialogProps: Array<{ open: boolean; bridge: unknown }> = []; mock.module('next-themes', () => ({ useTheme: () => ({ theme: undefined }), @@ -53,6 +54,13 @@ mock.module('./CloneDialog', () => ({ }, })); +mock.module('./RemoteProjectDialog', () => ({ + RemoteProjectDialog: (props: { open: boolean; bridge: unknown }) => { + remoteDialogProps.push(props); + return
; + }, +})); + mock.module('./AuthModal', () => ({ AuthModal: () => null, })); @@ -130,6 +138,7 @@ describe('NavigatorApp launcher runtime behavior', () => { themeBridgeCalls = []; createDialogProps = []; cloneDialogProps = []; + remoteDialogProps = []; }); afterEach(() => { @@ -151,6 +160,7 @@ describe('NavigatorApp launcher runtime behavior', () => { expectVisualClassTokens(chromeRow.className, ['inset-x-0', 'h-9']); expect(screen.getByTestId('nav-open').getAttribute('data-electron-no-drag')).toBeNull(); expect(screen.getByTestId('nav-create-new').getAttribute('data-electron-no-drag')).toBeNull(); + expect(screen.getByTestId('nav-open-remote').textContent).toContain('Open over SSH'); await screen.findByTestId('nav-recent-list'); expect(document.querySelector('[data-electron-no-drag]')).toBeNull(); }); @@ -183,6 +193,12 @@ describe('NavigatorApp launcher runtime behavior', () => { }); expect(createDialogProps.at(-1)?.bridge).toBe(bridge); + fireEvent.click(screen.getByTestId('nav-open-remote')); + await waitFor(() => { + expect(screen.getByTestId('remote-project-dialog').getAttribute('data-open')).toBe('true'); + }); + expect(remoteDialogProps.at(-1)?.bridge).toBe(bridge); + fireEvent.click(screen.getByTestId('nav-clone')); await waitFor(() => { expect(screen.getByTestId('clone-dialog').getAttribute('data-open')).toBe('true'); @@ -219,8 +235,9 @@ describe('NavigatorApp launcher runtime behavior', () => { fireEvent.click(await screen.findByText('Recent Project')); const overlay = await screen.findByTestId('nav-opening-overlay'); - // Label is the path's last segment, not the full path. - expect(overlay.textContent).toContain('Opening recent'); + // Recent rows use their saved human-readable project name, never an opaque + // local/SSH state key. + expect(overlay.textContent).toContain('Opening Recent Project'); expect(overlay.getAttribute('role')).toBe('status'); // Failure-path parity: the main-side wrapper swallows errors and resolves @@ -302,4 +319,32 @@ describe('NavigatorApp launcher runtime behavior', () => { expect(plainRow.textContent).toContain('/Users/x/plain-notes'); expect(plainRow.textContent).not.toContain('worktree'); }); + + test('remote recents show an SSH badge plus machine and canonical remote path', async () => { + const bridge = createBridge(); + bridge.project.listRecent = mock(() => + Promise.resolve([ + { + path: 'ssh:machine-1:%2Fsrv%2Fknowledge', + name: 'knowledge', + remote: { + kind: 'ssh', + machineId: 'machine-1', + machineName: 'Build box', + path: '/srv/knowledge', + platform: 'linux', + pathSeparator: '/', + }, + }, + ]), + ); + await renderNavigator(bridge); + + const list = await screen.findByTestId('nav-recent-list'); + expect(list.textContent).toContain('knowledge'); + expect(list.textContent).toContain('SSH'); + expect(list.textContent).toContain('Build box • /srv/knowledge'); + expect(list.textContent).not.toContain('ssh:machine-1'); + expect(list.querySelector('svg.lucide-server')).not.toBeNull(); + }); }); diff --git a/packages/app/src/components/NavigatorApp.tsx b/packages/app/src/components/NavigatorApp.tsx index 71238b5f4..f6ac74161 100644 --- a/packages/app/src/components/NavigatorApp.tsx +++ b/packages/app/src/components/NavigatorApp.tsx @@ -15,7 +15,15 @@ */ import { Trans, useLingui } from '@lingui/react/macro'; -import { Folder, FolderOpenIcon, GitBranch, Loader2Icon, PlusIcon, XIcon } from 'lucide-react'; +import { + Folder, + FolderOpenIcon, + GitBranch, + Loader2Icon, + PlusIcon, + ServerIcon, + XIcon, +} from 'lucide-react'; import { useTheme } from 'next-themes'; import { type ComponentType, lazy, Suspense, useEffect, useState } from 'react'; import { useThemeBridge } from '@/hooks/use-theme-bridge'; @@ -47,6 +55,7 @@ import { OkIcon } from './icons/ok'; import { McpConsentDialog } from './McpConsentDialog'; import { PackCardGrid } from './PackCardGrid'; import { basenameOf } from './project-switcher-recents'; +import { RemoteProjectDialog } from './RemoteProjectDialog'; import { Badge } from './ui/badge'; import { Button } from './ui/button'; @@ -94,6 +103,7 @@ export function NavigatorApp({ bridge }: { bridge: OkDesktopBridge }) { const [openingLabel, setOpeningLabel] = useState(null); const [cloneDialogOpen, setCloneDialogOpen] = useState(false); const [createDialogOpen, setCreateDialogOpen] = useState(false); + const [remoteDialogOpen, setRemoteDialogOpen] = useState(false); // Starter pack chosen on the packs-forward first-run grid, plus the pack // list. Threaded into CreateProjectDialog so it can name the pack as // read-only context in its description and seed the fresh project with it; @@ -157,7 +167,7 @@ export function NavigatorApp({ bridge }: { bridge: OkDesktopBridge }) { // Best-effort batched HEAD-branch read for every non-missing entry. // `readHeadBranch` is a pure-fs read (no git subprocess) and never // throws; the IPC layer can still reject if the bridge is unavailable. - const eligible = result.filter((r) => !r.missing); + const eligible = result.filter((r) => !r.missing && r.remote === undefined); const entries = await Promise.all( eligible.map(async (r): Promise<[string, string | null]> => { try { @@ -244,6 +254,8 @@ export function NavigatorApp({ bridge }: { bridge: OkDesktopBridge }) { const onCreate = () => setCreateDialogOpen(true); + const onOpenRemote = () => setRemoteDialogOpen(true); + // First-run pack pick → open the create dialog with the pack pre-selected // and the full pack list so the dialog's Select can switch in-place. const onPackSelect = (packId: OkPackId, packs: OkSeedPackInfo[]) => { @@ -263,8 +275,8 @@ export function NavigatorApp({ bridge }: { bridge: OkDesktopBridge }) { } }; - const onOpenRecent = (path: string) => - openWithIndicator(path, 'recents', displayNameForPath(path)); + const onOpenRecent = (project: RecentProject) => + openWithIndicator(project.path, 'recents', project.name); const onRemoveRecent = (path: string) => runWithErrorState(async () => { @@ -348,9 +360,10 @@ export function NavigatorApp({ bridge }: { bridge: OkDesktopBridge }) { onOpenFolder={onOpenFolder} onClone={onClone} onCreate={onCreate} + onOpenRemote={onOpenRemote} /> ) : ( -
+
+
)} @@ -413,7 +433,7 @@ export function NavigatorApp({ bridge }: { bridge: OkDesktopBridge }) { key={r.path} project={r} branch={recentBranches.get(r.path) ?? null} - onOpen={() => onOpenRecent(r.path)} + onOpen={() => onOpenRecent(r)} onRemove={() => onRemoveRecent(r.path)} /> ))} @@ -442,6 +462,12 @@ export function NavigatorApp({ bridge }: { bridge: OkDesktopBridge }) { packs={createPacks} /> + + { @@ -573,11 +599,13 @@ function FirstRunPacksView({ onOpenFolder, onClone, onCreate, + onOpenRemote, }: { onPackSelect: (packId: OkPackId, packs: OkSeedPackInfo[]) => void; onOpenFolder: () => void; onClone: () => void; onCreate: () => void; + onOpenRemote: () => void; }) { const [packs, setPacks] = useState(null); @@ -670,6 +698,16 @@ function FirstRunPacksView({
@@ -707,7 +745,11 @@ function RecentRow({
{/* Uniform folder icon for every row — worktree vs project is conveyed by the worktree pill + the branch chip, not by the icon. */} -
diff --git a/packages/app/src/components/ProjectSwitcher.dom.test.tsx b/packages/app/src/components/ProjectSwitcher.dom.test.tsx index 7a10f4006..f564d5429 100644 --- a/packages/app/src/components/ProjectSwitcher.dom.test.tsx +++ b/packages/app/src/components/ProjectSwitcher.dom.test.tsx @@ -389,6 +389,73 @@ describe('ProjectSwitcher dropdown behavior', () => { expect(newWorktreeProps.at(-1)?.initialBranchName).toBe(''); }); + test('remote projects show an accessible SSH identity and suppress worktree affordances', async () => { + const bridge = createBridge(); + bridge.config.projectPath = 'ssh:machine-1:%2Fsrv%2Fknowledge'; + Object.assign(bridge.config, { + remote: { + kind: 'ssh', + machineId: 'machine-1', + machineName: 'Build box', + path: '/srv/knowledge', + platform: 'linux', + pathSeparator: '/', + }, + }); + + render(); + const trigger = screen.getByTestId('project-switcher-trigger'); + expect(trigger.getAttribute('title')).toBe('Build box • /srv/knowledge'); + expect(trigger.getAttribute('title')).not.toContain('ssh:machine-1'); + expect(trigger.getAttribute('aria-label')).toBe( + 'Open project menu for Current Project on Build box at /srv/knowledge', + ); + expect(screen.getByTestId('project-switcher-remote-location').textContent).toContain( + 'Build box • /srv/knowledge', + ); + + await openMenu(); + expect(screen.queryByTestId('project-switcher-new-worktree')).toBeNull(); + expect(screen.queryByTestId('new-worktree-dialog')).toBeNull(); + expect(screen.getByTestId('project-switcher-open-folder').textContent).toContain( + 'Open local folder', + ); + + act(() => menuActionCb?.('new-worktree')); + expect(screen.queryByTestId('new-worktree-dialog')).toBeNull(); + }); + + test('project search matches the displayed SSH machine and remote path, not the opaque key', async () => { + const bridge = createBridge(); + bridge.project.listRecent = mock(() => + Promise.resolve([ + { + name: 'Remote Notes', + path: 'ssh:machine-2:%2Fopt%2Fteam-notes', + lastOpenedAt: '2026-07-01', + remote: { + machineId: 'machine-2', + machineName: 'Deploy box', + path: '/opt/team-notes', + }, + }, + ]), + ); + render(); + await openMenu(); + + const search = screen.getByTestId('project-switcher-search'); + fireEvent.change(search, { target: { value: 'deploy box' } }); + expect( + await screen.findByTestId('project-switcher-recent-ssh:machine-2:%2Fopt%2Fteam-notes'), + ).not.toBeNull(); + + fireEvent.change(search, { target: { value: '/opt/team-notes' } }); + expect( + await screen.findByTestId('project-switcher-recent-ssh:machine-2:%2Fopt%2Fteam-notes'), + ).not.toBeNull(); + }); + test('the flyout "Create worktree …" action opens the dialog PRE-FILLED with the typed name', async () => { // A git-enriched current-project recent so RecentProjectsMenu renders the // grouped flyout for it (the create option only shows for the current diff --git a/packages/app/src/components/ProjectSwitcher.tsx b/packages/app/src/components/ProjectSwitcher.tsx index 6425f85df..30ba7bc8a 100644 --- a/packages/app/src/components/ProjectSwitcher.tsx +++ b/packages/app/src/components/ProjectSwitcher.tsx @@ -17,7 +17,15 @@ */ import { Trans, useLingui } from '@lingui/react/macro'; -import { ChevronsUpDown, FolderOpen, GitBranch, LayoutGrid, Plus, Search } from 'lucide-react'; +import { + ChevronsUpDown, + FolderOpen, + GitBranch, + LayoutGrid, + Plus, + Search, + Server, +} from 'lucide-react'; import { useEffect, useRef, useState } from 'react'; import { DropdownMenu, @@ -33,6 +41,7 @@ import { useCurrentBranch } from '@/hooks/use-current-branch'; import { useWorktrees } from '@/hooks/use-worktrees'; import type { OkDesktopBridge, RecentProjectEntry } from '@/lib/desktop-bridge-types'; import { runWithToast as runWithToastBase } from '@/lib/error-state'; +import { desktopProjectLocation } from '@/lib/remote-project-display'; import { cn } from '@/lib/utils'; import { CreateProjectDialog } from './CreateProjectDialog'; import { NewWorktreeDialog } from './NewWorktreeDialog'; @@ -92,6 +101,7 @@ export function ProjectSwitcher({ bridge }: ProjectSwitcherProps) { // shared with the command palette). Feeds the switcher's search so an // un-opened branch is reachable by name; null until it lands / off-desktop. const worktreeModel = useWorktrees(); + const isRemoteProject = bridge.config.remote != null; const isElectronHost = typeof window !== 'undefined' && window.okDesktop != null; // Tracks whether a real `pointerdown` reached the trigger this interaction. @@ -154,6 +164,9 @@ export function ProjectSwitcher({ bridge }: ProjectSwitcherProps) { // dropdown (which lazy-loads the worktree list). useEffect(() => { return bridge.onMenuAction((action) => { + if (isRemoteProject && (action === 'new-worktree' || action === 'switch-worktree')) { + return; + } if (action === 'new-worktree') { setOpen(false); setFlyoutPath(null); @@ -163,7 +176,7 @@ export function ProjectSwitcher({ bridge }: ProjectSwitcherProps) { setOpen(true); } }); - }, [bridge]); + }, [bridge, isRemoteProject]); const onOpenFolder = () => { handleOpenChange(false); @@ -216,7 +229,7 @@ export function ProjectSwitcher({ bridge }: ProjectSwitcherProps) { const menuRecents = isSearching ? loadedRecents.filter((r) => !r.isLinkedWorktree) : loadedRecents; - const menuWorktreeModel = isSearching ? null : worktreeModel; + const menuWorktreeModel = isSearching || isRemoteProject ? null : worktreeModel; return ( <> @@ -235,11 +248,15 @@ export function ProjectSwitcher({ bridge }: ProjectSwitcherProps) { {bridge.config.projectName} + {bridge.config.remote ? ( + + + ) : null} {branch !== null ? ( - - {group.project.path} + + {recentProjectDisplayPath(group.project)} {projectIsCurrent ? ( @@ -676,7 +680,7 @@ function SearchResults({ const matches = (text: string): boolean => text.toLowerCase().includes(query); const projectMatches = recents.filter( - (r) => !r.isLinkedWorktree && (matches(r.name) || matches(r.path)), + (r) => !r.isLinkedWorktree && (matches(r.name) || matches(recentProjectDisplayPath(r))), ); const openedWorktreeMatches = recents.filter( (r) => r.isLinkedWorktree === true && (matches(r.branch ?? '') || matches(r.path)), @@ -720,7 +724,11 @@ function SearchResults({ className="flex w-full min-w-0 flex-col items-start gap-0.5" data-testid={`project-switcher-recent-${r.path}`} > - + ))} {openedWorktreeMatches.map((r) => ( diff --git a/packages/app/src/components/RemoteProjectDialog.dom.test.tsx b/packages/app/src/components/RemoteProjectDialog.dom.test.tsx new file mode 100644 index 000000000..c16d4023e --- /dev/null +++ b/packages/app/src/components/RemoteProjectDialog.dom.test.tsx @@ -0,0 +1,449 @@ +import { afterEach, beforeEach, describe, expect, test } from 'bun:test'; +import { cleanup, render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import type { OkDesktopBridge, OkSshMachine } from '@/lib/desktop-bridge-types'; +import { RemoteProjectDialog } from './RemoteProjectDialog'; + +// Radix Dialog's focus trap reaches for DOM globals not promoted by the shared +// jsdom preload. Keep the shims local, matching the other dialog DOM tests. +type WindowGlobals = { NodeFilter?: typeof NodeFilter }; +type GlobalWithDomShims = typeof globalThis & + WindowGlobals & { window?: WindowGlobals; ResizeObserver?: unknown }; +const globalWithDomShims = globalThis as GlobalWithDomShims; +if ( + globalWithDomShims.NodeFilter === undefined && + globalWithDomShims.window?.NodeFilter !== undefined +) { + globalWithDomShims.NodeFilter = globalWithDomShims.window.NodeFilter; +} +if (globalWithDomShims.ResizeObserver === undefined) { + class NoopResizeObserver { + observe() {} + unobserve() {} + disconnect() {} + } + globalWithDomShims.ResizeObserver = NoopResizeObserver; +} +type ElementPointerShims = Element & { + hasPointerCapture?: () => boolean; + releasePointerCapture?: () => void; + scrollIntoView?: () => void; +}; +const elementPrototype = Element.prototype as ElementPointerShims; +elementPrototype.hasPointerCapture ??= () => false; +elementPrototype.releasePointerCapture ??= () => {}; +elementPrototype.scrollIntoView ??= () => {}; + +const machine: OkSshMachine = { + id: 'machine-1', + name: 'Staging', + host: 'staging-box', + port: 2222, +}; + +interface BridgeCalls { + save: Array<{ id?: string; name: string; host: string; port?: number }>; + remove: string[]; + test: string[]; + listDirectories: Array<{ machineId: string; path: string }>; + open: Array<{ machineId: string; path: string }>; +} + +function makeBridge(options?: { + machines?: OkSshMachine[]; + testResult?: { ok: true } | { ok: false; error: string }; + openResult?: boolean; +}) { + const calls: BridgeCalls = { + save: [], + remove: [], + test: [], + listDirectories: [], + open: [], + }; + const savedMachines = options?.machines ?? [machine]; + const bridge = { + remote: { + listMachines: async () => savedMachines, + saveMachine: async (input: { id?: string; name: string; host: string; port?: number }) => { + calls.save.push(input); + return { id: 'machine-saved', ...input }; + }, + removeMachine: async (machineId: string) => { + calls.remove.push(machineId); + }, + testMachine: async (machineId: string) => { + calls.test.push(machineId); + return options?.testResult ?? { ok: true as const }; + }, + listDirectories: async (input: { machineId: string; path: string }) => { + calls.listDirectories.push(input); + if (input.path === '~') { + return { + path: '/home/dev', + parentPath: '/home', + directories: [{ name: 'workspace', path: '/home/dev/workspace' }], + }; + } + return { + path: input.path, + parentPath: '/home/dev', + directories: [], + }; + }, + openProject: async (input: { machineId: string; path: string }) => { + calls.open.push(input); + return options?.openResult ?? true; + }, + }, + } as unknown as OkDesktopBridge; + + return { bridge, calls }; +} + +describe('RemoteProjectDialog', () => { + beforeEach(() => cleanup()); + afterEach(() => cleanup()); + + test('browses a saved machine, tests the connection, and opens the selected path', async () => { + const user = userEvent.setup(); + const { bridge, calls } = makeBridge(); + const openChanges: boolean[] = []; + render( + openChanges.push(next)} bridge={bridge} />, + ); + + expect((await screen.findByRole('combobox', { name: 'SSH machine' })).textContent).toContain( + 'Staging', + ); + await waitFor(() => { + expect(calls.listDirectories).toEqual([{ machineId: 'machine-1', path: '~' }]); + }); + expect(await screen.findByDisplayValue('/home/dev')).not.toBeNull(); + + await user.click(screen.getByRole('button', { name: 'Test connection' })); + expect(await screen.findByText('Connection successful.')).not.toBeNull(); + expect(calls.test).toEqual(['machine-1']); + + await user.click(screen.getByRole('button', { name: 'workspace' })); + await waitFor(() => { + expect(calls.listDirectories.at(-1)).toEqual({ + machineId: 'machine-1', + path: '/home/dev/workspace', + }); + }); + expect(await screen.findByDisplayValue('/home/dev/workspace')).not.toBeNull(); + + await user.click(screen.getByRole('button', { name: 'Up' })); + await waitFor(() => { + expect(calls.listDirectories.at(-1)).toEqual({ + machineId: 'machine-1', + path: '/home/dev', + }); + }); + + const pathInput = await screen.findByLabelText('Remote path'); + await user.clear(pathInput); + await user.type(pathInput, '/srv/projects/demo '); + await user.click(screen.getByRole('button', { name: 'Go' })); + await waitFor(() => { + expect(calls.listDirectories.at(-1)).toEqual({ + machineId: 'machine-1', + path: '/srv/projects/demo ', + }); + }); + + await user.click(screen.getByRole('button', { name: 'Open project' })); + await waitFor(() => { + expect(calls.open).toEqual([{ machineId: 'machine-1', path: '/srv/projects/demo ' }]); + }); + expect(openChanges).toContain(false); + }); + + test('shows every saved remote when the SSH machine box is clicked', async () => { + const user = userEvent.setup(); + const production: OkSshMachine = { + id: 'machine-2', + name: 'Production', + host: 'prod.example.com', + }; + const { bridge } = makeBridge({ machines: [machine, production] }); + render( {}} bridge={bridge} />); + + const machineBox = await screen.findByRole('combobox', { name: 'SSH machine' }); + expect(machineBox.textContent).toContain('staging-box:2222'); + await user.click(machineBox); + + expect(await screen.findByRole('option', { name: /Staging/ })).not.toBeNull(); + const productionOption = await screen.findByRole('option', { name: /Production/ }); + expect(productionOption.textContent).toContain('prod.example.com'); + await user.click(productionOption); + expect(machineBox.textContent).toContain('Production'); + }); + + test('can switch saved machines or close while the initial SSH browse is pending', async () => { + const user = userEvent.setup(); + const production: OkSshMachine = { + id: 'machine-2', + name: 'Production', + host: 'prod.example.com', + }; + const { bridge, calls } = makeBridge({ machines: [machine, production] }); + let finishFirstBrowse: (() => void) | undefined; + const firstBrowse = new Promise((resolve) => { + finishFirstBrowse = resolve; + }); + bridge.remote.listDirectories = async (input) => { + calls.listDirectories.push(input); + if (input.machineId === machine.id) await firstBrowse; + return { + path: input.machineId === production.id ? '/srv/production' : '/home/staging', + parentPath: '/', + directories: [], + }; + }; + const openChanges: boolean[] = []; + render( + openChanges.push(next)} bridge={bridge} />, + ); + + await waitFor(() => + expect(calls.listDirectories).toEqual([{ machineId: machine.id, path: '~' }]), + ); + const machineBox = await screen.findByRole('combobox', { name: 'SSH machine' }); + expect(machineBox.hasAttribute('disabled')).toBe(false); + expect(screen.getByRole('button', { name: 'Cancel' }).hasAttribute('disabled')).toBe(false); + + await user.click(machineBox); + await user.click(await screen.findByRole('option', { name: /Production/ })); + await waitFor(() => + expect(calls.listDirectories).toContainEqual({ machineId: production.id, path: '~' }), + ); + expect(await screen.findByDisplayValue('/srv/production')).not.toBeNull(); + + await user.click(screen.getByRole('button', { name: 'Cancel' })); + expect(openChanges).toEqual([false]); + finishFirstBrowse?.(); + }); + + test('ignores a dismissed connection test after a new dialog lifecycle starts', async () => { + const user = userEvent.setup(); + const { bridge, calls } = makeBridge(); + let finishTest: ((result: { ok: true }) => void) | undefined; + bridge.remote.testMachine = async (machineId) => { + calls.test.push(machineId); + return new Promise<{ ok: true }>((resolve) => { + finishTest = resolve; + }); + }; + let finishReopenedBrowse: (() => void) | undefined; + let browseCount = 0; + bridge.remote.listDirectories = async (input) => { + calls.listDirectories.push(input); + browseCount += 1; + if (browseCount === 2) { + await new Promise((resolve) => { + finishReopenedBrowse = resolve; + }); + } + return { path: '/home/dev', parentPath: '/home', directories: [] }; + }; + + const renderDialog = (open: boolean) => ( + {}} bridge={bridge} /> + ); + const view = render(renderDialog(true)); + await screen.findByDisplayValue('/home/dev'); + await user.click(screen.getByRole('button', { name: 'Test connection' })); + await waitFor(() => expect(calls.test).toEqual([machine.id])); + + view.rerender(renderDialog(false)); + view.rerender(renderDialog(true)); + await waitFor(() => expect(calls.listDirectories).toHaveLength(2)); + expect(screen.getByRole('button', { name: 'Open project' }).hasAttribute('disabled')).toBe( + true, + ); + + finishTest?.({ ok: true }); + await Promise.resolve(); + expect(screen.queryByText('Connection successful.')).toBeNull(); + expect(screen.getByRole('button', { name: 'Open project' }).hasAttribute('disabled')).toBe( + true, + ); + + finishReopenedBrowse?.(); + await waitFor(() => + expect(screen.getByRole('button', { name: 'Open project' }).hasAttribute('disabled')).toBe( + false, + ), + ); + }); + + test('keeps keyboard focus on a folder while its navigation request is pending', async () => { + const user = userEvent.setup(); + const { bridge, calls } = makeBridge(); + let finishFolderBrowse: (() => void) | undefined; + bridge.remote.listDirectories = async (input) => { + calls.listDirectories.push(input); + if (input.path !== '~') { + await new Promise((resolve) => { + finishFolderBrowse = resolve; + }); + } + return input.path === '~' + ? { + path: '/home/dev', + parentPath: '/home', + directories: [{ name: 'workspace', path: '/home/dev/workspace' }], + } + : { path: input.path, parentPath: '/home/dev', directories: [] }; + }; + render( {}} bridge={bridge} />); + + const folder = await screen.findByRole('button', { name: 'workspace' }); + await user.click(folder); + await waitFor(() => expect(calls.listDirectories).toHaveLength(2)); + expect(document.activeElement).toBe(folder); + expect(folder.hasAttribute('disabled')).toBe(false); + + finishFolderBrowse?.(); + await screen.findByText('No folders in this location.'); + await waitFor(() => expect(document.activeElement).toBe(screen.getByLabelText('Remote path'))); + }); + + test('adds a credential-free machine and validates its optional port', async () => { + const user = userEvent.setup(); + const { bridge, calls } = makeBridge({ machines: [] }); + render( {}} bridge={bridge} />); + + expect(await screen.findByText(/Passwords and private keys are never stored/)).not.toBeNull(); + expect(screen.queryByLabelText(/Password/i)).toBeNull(); + + await user.click(screen.getByRole('button', { name: 'Save machine' })); + const nameError = await screen.findByRole('alert'); + expect(nameError.textContent).toContain('Enter a machine name.'); + expect(screen.getByLabelText('Machine name').getAttribute('aria-invalid')).toBe('true'); + expect(screen.getByLabelText('Machine name').getAttribute('aria-describedby')).toBe( + nameError.id, + ); + + await user.type(screen.getByLabelText('Machine name'), 'Development'); + await user.type(screen.getByLabelText('SSH host or config alias'), 'devbox'); + await user.type(screen.getByLabelText('Port (optional)'), '70000'); + await user.click(screen.getByRole('button', { name: 'Save machine' })); + expect((await screen.findByRole('alert')).textContent).toContain( + 'Enter a whole-number port from 1 to 65535.', + ); + expect(screen.getByLabelText('Port (optional)').getAttribute('aria-invalid')).toBe('true'); + expect(screen.getByLabelText('Port (optional)').getAttribute('aria-describedby')).toBe( + screen.getByRole('alert').id, + ); + expect(calls.save).toEqual([]); + + await user.clear(screen.getByLabelText('Port (optional)')); + await user.type(screen.getByLabelText('Port (optional)'), '2200'); + await user.click(screen.getByRole('button', { name: 'Save machine' })); + + await waitFor(() => { + expect(calls.save).toEqual([{ name: 'Development', host: 'devbox', port: 2200 }]); + }); + expect((await screen.findByRole('combobox', { name: 'SSH machine' })).textContent).toContain( + 'Development', + ); + }); + + test('surfaces connection failures and confirms saved-machine removal without touching remote data', async () => { + const user = userEvent.setup(); + const { bridge, calls } = makeBridge({ + testResult: { ok: false, error: 'Permission denied (publickey).' }, + }); + render( {}} bridge={bridge} />); + + await screen.findByRole('combobox', { name: 'SSH machine' }); + await waitFor(() => expect(calls.listDirectories).toHaveLength(1)); + await user.click(screen.getByRole('button', { name: 'Test connection' })); + expect((await screen.findByRole('alert')).textContent).toContain( + 'Permission denied (publickey).', + ); + + await user.click(screen.getByRole('button', { name: 'Remove Staging' })); + expect(calls.remove).toEqual([]); + expect(await screen.findByRole('heading', { name: 'Remove SSH machine?' })).not.toBeNull(); + expect(screen.getByText(/saved recent-project and session metadata/)).not.toBeNull(); + expect( + screen.getByText(/never deletes or changes projects, files, or other data/), + ).not.toBeNull(); + + await user.click(screen.getByRole('button', { name: 'Remove machine' })); + await waitFor(() => expect(calls.remove).toEqual(['machine-1'])); + expect(await screen.findByText('Add SSH machine')).not.toBeNull(); + }); + + test('keeps an in-flight open non-dismissible and announces its progress', async () => { + const user = userEvent.setup(); + const { bridge, calls } = makeBridge(); + let finishOpen: (() => void) | null = null; + const opening = new Promise((resolve) => { + finishOpen = () => resolve(true); + }); + bridge.remote.openProject = async (input) => { + calls.open.push(input); + return opening; + }; + const openChanges: boolean[] = []; + render( + openChanges.push(next)} bridge={bridge} />, + ); + + await screen.findByDisplayValue('/home/dev'); + await user.click(screen.getByRole('button', { name: 'Open project' })); + + expect((await screen.findByRole('status')).textContent).toContain('Opening remote project...'); + expect(screen.queryByRole('button', { name: 'Close' })).toBeNull(); + await user.keyboard('{Escape}'); + expect(openChanges).toEqual([]); + + finishOpen?.(); + await waitFor(() => expect(openChanges).toEqual([false])); + }); + + test('stays open without an error when native initialization consent is cancelled', async () => { + const user = userEvent.setup(); + const { bridge, calls } = makeBridge({ openResult: false }); + const openChanges: boolean[] = []; + render( + openChanges.push(next)} bridge={bridge} />, + ); + + await screen.findByDisplayValue('/home/dev'); + await user.click(screen.getByRole('button', { name: 'Open project' })); + + await waitFor(() => + expect(calls.open).toEqual([{ machineId: 'machine-1', path: '/home/dev' }]), + ); + expect(openChanges).toEqual([]); + expect(screen.queryByRole('alert')).toBeNull(); + expect(screen.getByRole('button', { name: 'Open project' }).hasAttribute('disabled')).toBe( + false, + ); + }); + + test('associates a directory failure with the path field and names the failed location', async () => { + const { bridge } = makeBridge(); + bridge.remote.listDirectories = async () => { + throw new Error( + "Error invoking remote method 'ok:remote:dispatch': RemoteProjectError: Permission denied.", + ); + }; + render( {}} bridge={bridge} />); + + const alert = await screen.findByRole('alert'); + expect(alert.textContent).toContain('Could not browse the SSH home directory.'); + expect(alert.textContent).toContain('Permission denied.'); + expect(alert.textContent).not.toContain('Error invoking remote method'); + expect(alert.textContent).not.toContain('RemoteProjectError'); + const path = screen.getByLabelText('Remote path'); + expect(path.getAttribute('aria-invalid')).toBe('true'); + expect(path.getAttribute('aria-describedby')).toBe(alert.id); + }); +}); diff --git a/packages/app/src/components/RemoteProjectDialog.test.ts b/packages/app/src/components/RemoteProjectDialog.test.ts new file mode 100644 index 000000000..54c7ac7f4 --- /dev/null +++ b/packages/app/src/components/RemoteProjectDialog.test.ts @@ -0,0 +1,80 @@ +import { describe, expect, test } from 'bun:test'; +import { + formatRemoteProjectError, + formatSshMachineTarget, + validateRemoteMachineDraft, +} from './RemoteProjectDialog'; + +describe('validateRemoteMachineDraft', () => { + test('trims a machine name and SSH target and omits an empty port', () => { + expect( + validateRemoteMachineDraft({ + name: ' Development ', + host: ' devbox ', + port: ' ', + }), + ).toEqual({ + ok: true, + value: { name: 'Development', host: 'devbox' }, + }); + }); + + test('accepts an explicit port at the inclusive SSH port boundaries', () => { + expect(validateRemoteMachineDraft({ name: 'Low', host: 'low', port: '1' })).toEqual({ + ok: true, + value: { name: 'Low', host: 'low', port: 1 }, + }); + expect(validateRemoteMachineDraft({ name: 'High', host: 'high', port: '65535' })).toEqual({ + ok: true, + value: { name: 'High', host: 'high', port: 65_535 }, + }); + }); + + test('rejects missing fields and invalid ports', () => { + expect(validateRemoteMachineDraft({ name: ' ', host: 'devbox', port: '' })).toEqual({ + ok: false, + error: 'name-required', + }); + expect(validateRemoteMachineDraft({ name: 'Dev', host: ' ', port: '' })).toEqual({ + ok: false, + error: 'host-required', + }); + + for (const port of ['0', '65536', '22.5', '2e1', '0x16', '+22', 'not-a-port']) { + expect(validateRemoteMachineDraft({ name: 'Dev', host: 'devbox', port })).toEqual({ + ok: false, + error: 'port-invalid', + }); + } + }); +}); + +describe('formatSshMachineTarget', () => { + test('only appends a port when one is explicitly configured', () => { + expect(formatSshMachineTarget({ host: 'devbox' })).toBe('devbox'); + expect(formatSshMachineTarget({ host: 'user@example.com', port: 2222 })).toBe( + 'user@example.com:2222', + ); + }); +}); + +describe('formatRemoteProjectError', () => { + test('removes Electron and remote-project implementation wrappers', () => { + expect( + formatRemoteProjectError( + new Error( + "Error invoking remote method 'ok:remote:dispatch': RemoteProjectError: Install failed.", + ), + 'Fallback', + ), + ).toBe('Install failed.'); + }); + + test('keeps actionable errors and uses the fallback for an empty or non-error value', () => { + expect(formatRemoteProjectError(new Error('Permission denied.'), 'Fallback')).toBe( + 'Permission denied.', + ); + expect(formatRemoteProjectError(new Error(''), 'Fallback')).toBe('Fallback'); + expect(formatRemoteProjectError('failed', 'Fallback')).toBe('Fallback'); + }); +}); diff --git a/packages/app/src/components/RemoteProjectDialog.tsx b/packages/app/src/components/RemoteProjectDialog.tsx new file mode 100644 index 000000000..08bd706e3 --- /dev/null +++ b/packages/app/src/components/RemoteProjectDialog.tsx @@ -0,0 +1,810 @@ +import { Trans, useLingui } from '@lingui/react/macro'; +import { ArrowUp, Folder, Loader2, Plus, Server, TestTube2, Trash2 } from 'lucide-react'; +import { type SyntheticEvent, useEffect, useId, useRef, useState } from 'react'; +import { Button } from '@/components/ui/button'; +import { + Dialog, + DialogBody, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { Select, SelectContent, SelectItem, SelectTrigger } from '@/components/ui/select'; +import type { OkDesktopBridge, OkSshMachine } from '@/lib/desktop-bridge-types'; + +type RemoteMachineValidationError = 'name-required' | 'host-required' | 'port-invalid'; + +type RemoteMachineValidationResult = + | { + ok: true; + value: { name: string; host: string; port?: number }; + } + | { ok: false; error: RemoteMachineValidationError }; + +type RemoteDirectoryListing = Awaited>; + +type PendingAction = 'save' | 'test' | 'browse' | 'open' | 'remove' | null; +type ErrorField = 'machine-name' | 'machine-host' | 'machine-port' | 'remote-path' | null; + +interface RemoteProjectDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + bridge: OkDesktopBridge; +} + +/** + * Normalize the credential-free SSH machine form before it crosses IPC. + * Keeping this pure makes the port boundary (1..65535, integer only) explicit + * and prevents whitespace-only aliases from becoming unusable saved entries. + */ +export function validateRemoteMachineDraft(input: { + name: string; + host: string; + port: string; +}): RemoteMachineValidationResult { + const name = input.name.trim(); + const host = input.host.trim(); + const portText = input.port.trim(); + + if (name === '') return { ok: false, error: 'name-required' }; + if (host === '') return { ok: false, error: 'host-required' }; + + if (portText === '') return { ok: true, value: { name, host } }; + + if (!/^\d+$/.test(portText)) { + return { ok: false, error: 'port-invalid' }; + } + const port = Number(portText); + if (port < 1 || port > 65_535) { + return { ok: false, error: 'port-invalid' }; + } + + return { ok: true, value: { name, host, port } }; +} + +/** Display target for a saved machine without reproducing SSH config details. */ +export function formatSshMachineTarget(machine: Pick): string { + return machine.port === undefined ? machine.host : `${machine.host}:${machine.port}`; +} + +export function formatRemoteProjectError(error: unknown, fallback: string): string { + if (!(error instanceof Error)) return fallback; + const message = error.message + .replace(/^Error invoking remote method 'ok:remote:dispatch':\s*/, '') + .replace(/^RemoteProjectError:\s*/, '') + .trim(); + return message === '' ? fallback : message; +} + +/** + * Add an SSH machine and browse a project on it using the desktop bridge. + * Authentication is deliberately delegated to the user's system SSH config + * and agent; this surface never accepts or persists passwords/private keys. + */ +export function RemoteProjectDialog({ open, onOpenChange, bridge }: RemoteProjectDialogProps) { + const { t } = useLingui(); + const machineSelectId = useId(); + const machineNameId = useId(); + const machineHostId = useId(); + const machinePortId = useId(); + const remotePathId = useId(); + const addMachineFormId = useId(); + const errorId = useId(); + + const [machines, setMachines] = useState(null); + const [selectedMachineId, setSelectedMachineId] = useState(null); + const [addingMachine, setAddingMachine] = useState(false); + const [machineName, setMachineName] = useState(''); + const [machineHost, setMachineHost] = useState(''); + const [machinePort, setMachinePort] = useState(''); + const [pathInput, setPathInput] = useState('~'); + const [listing, setListing] = useState(null); + const [machinesLoading, setMachinesLoading] = useState(false); + const [pendingAction, setPendingAction] = useState(null); + const [error, setError] = useState(null); + const [errorField, setErrorField] = useState(null); + const [status, setStatus] = useState(null); + const [machinePendingRemoval, setMachinePendingRemoval] = useState(null); + const browseRequestRef = useRef(0); + const dialogGenerationRef = useRef(0); + const remotePathInputRef = useRef(null); + const focusPathAfterBrowseRef = useRef(false); + + const selectedMachine = machines?.find((machine) => machine.id === selectedMachineId) ?? null; + const busy = machinesLoading || pendingAction !== null; + const blocking = + machinesLoading || + pendingAction === 'save' || + pendingAction === 'open' || + pendingAction === 'remove'; + + const loadDirectory = async (machineId: string, path: string): Promise => { + if (path.trim() === '') { + setError(t`Enter a remote path.`); + setErrorField('remote-path'); + return; + } + // POSIX permits leading/trailing spaces in directory names. Use trim only + // for the empty-value check; the path itself must cross IPC byte-for-byte. + const requestedPath = path; + + const requestId = browseRequestRef.current + 1; + browseRequestRef.current = requestId; + setPendingAction('browse'); + setError(null); + setErrorField(null); + setStatus(null); + setMachinePendingRemoval(null); + setPathInput(requestedPath); + + try { + const nextListing = await bridge.remote.listDirectories({ machineId, path: requestedPath }); + if (browseRequestRef.current === requestId) { + setListing(nextListing); + setPathInput(nextListing.path); + } + } catch (caught) { + if (browseRequestRef.current === requestId) { + const machineName = + machines?.find((machine) => machine.id === machineId)?.name ?? t`the SSH machine`; + const detail = formatRemoteProjectError( + caught, + t`Check that the path exists and that your SSH account can read it.`, + ); + setError(t`Could not browse ${requestedPath} on ${machineName}. ${detail}`); + setErrorField('remote-path'); + } + } + if (browseRequestRef.current === requestId) { + focusPathAfterBrowseRef.current = true; + setPendingAction(null); + } + }; + + useEffect(() => { + if (!open) return; + + const generation = dialogGenerationRef.current + 1; + dialogGenerationRef.current = generation; + let cancelled = false; + browseRequestRef.current += 1; + setMachines(null); + setSelectedMachineId(null); + setAddingMachine(false); + setMachineName(''); + setMachineHost(''); + setMachinePort(''); + setPathInput('~'); + setListing(null); + setMachinesLoading(true); + setPendingAction(null); + setError(null); + setErrorField(null); + setStatus(null); + setMachinePendingRemoval(null); + + void bridge.remote + .listMachines() + .then((savedMachines) => { + if (cancelled) return; + setMachines(savedMachines); + setSelectedMachineId(savedMachines[0]?.id ?? null); + setAddingMachine(savedMachines.length === 0); + }) + .catch((caught) => { + if (cancelled) return; + setMachines([]); + setAddingMachine(true); + setError(formatRemoteProjectError(caught, t`Failed to load SSH machines.`)); + setErrorField(null); + }) + .finally(() => { + if (!cancelled) setMachinesLoading(false); + }); + + return () => { + cancelled = true; + if (dialogGenerationRef.current === generation) dialogGenerationRef.current += 1; + browseRequestRef.current += 1; + }; + }, [open, bridge, t]); + + useEffect(() => { + if (!open || addingMachine || selectedMachineId === null || machinesLoading) return; + + let cancelled = false; + const requestId = browseRequestRef.current + 1; + browseRequestRef.current = requestId; + setPendingAction('browse'); + setError(null); + setErrorField(null); + setStatus(null); + setPathInput('~'); + setListing(null); + + void bridge.remote + .listDirectories({ machineId: selectedMachineId, path: '~' }) + .then((nextListing) => { + if (cancelled || browseRequestRef.current !== requestId) return; + setListing(nextListing); + setPathInput(nextListing.path); + }) + .catch((caught) => { + if (cancelled || browseRequestRef.current !== requestId) return; + const detail = formatRemoteProjectError( + caught, + t`Check that your SSH account can read its home directory.`, + ); + setError(t`Could not browse the SSH home directory. ${detail}`); + setErrorField('remote-path'); + }) + .finally(() => { + if (!cancelled && browseRequestRef.current === requestId) setPendingAction(null); + }); + + return () => { + cancelled = true; + if (browseRequestRef.current === requestId) browseRequestRef.current += 1; + }; + }, [open, addingMachine, selectedMachineId, machinesLoading, bridge, t]); + + useEffect(() => { + if (pendingAction !== null || !focusPathAfterBrowseRef.current) return; + focusPathAfterBrowseRef.current = false; + remotePathInputRef.current?.focus(); + }, [pendingAction]); + + const resetMessages = (): void => { + setError(null); + setErrorField(null); + setStatus(null); + }; + + const startAddingMachine = (): void => { + resetMessages(); + setMachineName(''); + setMachineHost(''); + setMachinePort(''); + setAddingMachine(true); + }; + + const cancelAddingMachine = (): void => { + resetMessages(); + setAddingMachine(false); + }; + + const submitMachine = async (event: SyntheticEvent): Promise => { + event.preventDefault(); + const validation = validateRemoteMachineDraft({ + name: machineName, + host: machineHost, + port: machinePort, + }); + + if (!validation.ok) { + const message = + validation.error === 'name-required' + ? t`Enter a machine name.` + : validation.error === 'host-required' + ? t`Enter an SSH host or config alias.` + : t`Enter a whole-number port from 1 to 65535.`; + setError(message); + setErrorField( + validation.error === 'name-required' + ? 'machine-name' + : validation.error === 'host-required' + ? 'machine-host' + : 'machine-port', + ); + setStatus(null); + return; + } + + setPendingAction('save'); + resetMessages(); + try { + const savedMachine = await bridge.remote.saveMachine(validation.value); + setMachines((current) => [ + ...(current ?? []).filter((machine) => machine.id !== savedMachine.id), + savedMachine, + ]); + setSelectedMachineId(savedMachine.id); + setAddingMachine(false); + setStatus(t`Machine saved.`); + } catch (caught) { + setError(formatRemoteProjectError(caught, t`Failed to save the SSH machine.`)); + } + setPendingAction(null); + }; + + const testConnection = async (): Promise => { + if (selectedMachine === null) return; + const generation = dialogGenerationRef.current; + setPendingAction('test'); + resetMessages(); + try { + const result = await bridge.remote.testMachine(selectedMachine.id); + if (dialogGenerationRef.current === generation) { + if (result.ok) { + setStatus(t`Connection successful.`); + } else { + setError(result.error.trim() === '' ? t`Connection failed.` : result.error); + } + } + } catch (caught) { + if (dialogGenerationRef.current === generation) { + setError(formatRemoteProjectError(caught, t`Connection failed.`)); + } + } + if (dialogGenerationRef.current === generation) setPendingAction(null); + }; + + const requestMachineRemoval = (): void => { + if (selectedMachine === null) return; + resetMessages(); + setMachinePendingRemoval(selectedMachine); + }; + + const removeMachine = async (): Promise => { + if (machinePendingRemoval === null || machines === null) return; + const target = machinePendingRemoval; + setPendingAction('remove'); + resetMessages(); + try { + await bridge.remote.removeMachine(target.id); + const remaining = machines.filter((machine) => machine.id !== target.id); + browseRequestRef.current += 1; + setMachines(remaining); + setListing(null); + setSelectedMachineId(remaining[0]?.id ?? null); + setAddingMachine(remaining.length === 0); + setMachinePendingRemoval(null); + setStatus(t`Machine removed.`); + } catch (caught) { + setError(formatRemoteProjectError(caught, t`Failed to remove the SSH machine.`)); + } + setPendingAction(null); + }; + + const browseToInput = (event: SyntheticEvent): void => { + event.preventDefault(); + if (selectedMachine !== null) void loadDirectory(selectedMachine.id, pathInput); + }; + + const openProject = async (): Promise => { + if (selectedMachine === null) return; + if (pathInput.trim() === '') { + setError(t`Enter a remote path.`); + setErrorField('remote-path'); + return; + } + + setPendingAction('open'); + resetMessages(); + try { + const opened = await bridge.remote.openProject({ + machineId: selectedMachine.id, + path: pathInput, + }); + if (opened) onOpenChange(false); + } catch (caught) { + setError(formatRemoteProjectError(caught, t`Failed to open the remote project.`)); + } + setPendingAction(null); + }; + + return ( + { + if (!nextOpen && blocking) return; + onOpenChange(nextOpen); + }} + > + { + if (blocking) event.preventDefault(); + }} + onInteractOutside={(event) => { + if (blocking) event.preventDefault(); + }} + onPointerDownOutside={(event) => { + if (blocking) event.preventDefault(); + }} + > + + + {machinePendingRemoval ? ( + Remove SSH machine? + ) : ( + Open remote project + )} + + + {machinePendingRemoval ? ( + Remove the saved connection for {machinePendingRemoval.name}? + ) : ( + + Connect to an SSH machine and open a project directly from its filesystem. + + )} + + + + + {machinePendingRemoval ? ( +
+

+ + This removes the machine and its saved recent-project and session metadata from + OpenKnowledge. + +

+

+ + It never deletes or changes projects, files, or other data on the remote machine. + +

+
+ ) : machinesLoading ? ( +
+ + Loading SSH machines... +
+ ) : addingMachine ? ( +
+
+
+

+ Add SSH machine +

+

+ + OpenKnowledge uses your system SSH config and SSH agent. Passwords and private + keys are never stored. + +

+

+ + The remote macOS or Linux machine needs Node.js 24 or newer and Git 2.31.0 or + newer in a POSIX-compatible login shell. OpenKnowledge installs its remote + support in your SSH home directory automatically. + +

+
+ {(machines?.length ?? 0) > 0 ? ( + + ) : null} +
+ +
+ + setMachineName(event.target.value)} + placeholder={t`Development server`} + autoFocus + disabled={busy} + aria-invalid={errorField === 'machine-name'} + aria-describedby={errorField === 'machine-name' ? errorId : undefined} + /> +
+ +
+ + setMachineHost(event.target.value)} + placeholder={t`devbox or user@example.com`} + autoComplete="off" + spellCheck={false} + disabled={busy} + aria-invalid={errorField === 'machine-host'} + aria-describedby={errorField === 'machine-host' ? errorId : undefined} + /> +
+ +
+ + setMachinePort(event.target.value)} + placeholder="22" + inputMode="numeric" + autoComplete="off" + disabled={busy} + aria-invalid={errorField === 'machine-port'} + aria-describedby={errorField === 'machine-port' ? errorId : undefined} + /> +
+
+ ) : selectedMachine !== null ? ( +
+
+
+ + +
+
+ +
+ + +
+
+

+ + Testing also installs or updates matching OpenKnowledge support in the SSH + user's home directory. + +

+
+ +
+ +
+ + setPathInput(event.target.value)} + spellCheck={false} + disabled={busy} + aria-invalid={errorField === 'remote-path'} + aria-describedby={errorField === 'remote-path' ? errorId : undefined} + /> + +
+

+ + OpenKnowledge checks the folder first and asks before creating project files. + +

+
+ +
+ {pendingAction === 'browse' && listing !== null ? ( +
+ + Loading folders... +
+ ) : null} + {listing === null && pendingAction === 'browse' ? ( +
+ + Loading folders... +
+ ) : listing === null ? ( +

+ Enter a path to browse its folders. +

+ ) : listing.directories.length === 0 ? ( +

+ No folders in this location. +

+ ) : ( +
+ {listing.directories.map((directory) => ( + + ))} +
+ )} +
+
+ ) : null} + + {error !== null ? ( + + ) : null} + {pendingAction === 'open' ? ( +

+ Opening remote project... +

+ ) : null} + {status !== null ? ( +

+ {status} +

+ ) : null} +
+ + + {machinePendingRemoval ? ( + <> + + + + ) : ( + + )} + {!machinePendingRemoval && addingMachine && !machinesLoading ? ( + + ) : !machinePendingRemoval && selectedMachine !== null && !machinesLoading ? ( + + ) : null} + +
+
+ ); +} diff --git a/packages/app/src/components/TerminalPanel.dom.test.tsx b/packages/app/src/components/TerminalPanel.dom.test.tsx index 6f0bfcef0..8770f8568 100644 --- a/packages/app/src/components/TerminalPanel.dom.test.tsx +++ b/packages/app/src/components/TerminalPanel.dom.test.tsx @@ -193,7 +193,7 @@ mock.module('next-themes', () => ({ type CreateResult = | { ok: true; ptyId: string } - | { ok: false; reason: 'no-project' | 'not-consented' }; + | { ok: false; reason: 'no-project' | 'not-consented' | 'remote-unavailable' }; const WIRED: ClaudeReadiness = { claude: 'present', mcp: 'wired' }; @@ -274,8 +274,28 @@ function makeBridge( }; } +function makeRemoteBridge() { + const harness = makeBridge({ ok: true, ptyId: 'pty-remote' }); + Object.assign(harness.bridge.config, { + projectPath: 'ssh:machine-1:%2Fsrv%2Fknowledge', + remote: { + kind: 'ssh', + machineId: 'machine-1', + machineName: 'Build box', + path: '/srv/knowledge', + platform: 'linux', + pathSeparator: '/', + }, + }); + return harness; +} + const { TerminalPanel } = await import('./TerminalPanel'); const { XTERM_DARK_THEME, XTERM_LIGHT_THEME } = await import('./terminal-theme'); +const { hashFromAssetPath } = await import('../lib/doc-hash'); +const { __resetPageListCacheForTests, setPageListCache } = await import( + '../editor/page-list-cache' +); describe('TerminalPanel', () => { beforeEach(() => { @@ -285,10 +305,12 @@ describe('TerminalPanel', () => { allROs = []; webglThrows = false; mockResolvedTheme = 'dark'; + __resetPageListCacheForTests(); (globalThis as { ResizeObserver: unknown }).ResizeObserver = MockResizeObserver; }); afterEach(() => { cleanup(); + __resetPageListCacheForTests(); }); test('mounts an accessible region, configures xterm for a11y, and creates a PTY sized to the fitted terminal', async () => { @@ -540,6 +562,22 @@ describe('TerminalPanel', () => { ); }); + test('dropping local files into a remote terminal never writes their Finder paths', async () => { + const { bridge, terminal } = makeRemoteBridge(); + render(); + await waitFor(() => expect(lastTerm?.onDataCb).toBeTruthy()); + + const container = document.querySelector('[data-terminal-status]'); + if (container === null) throw new Error('terminal container not found'); + + const localFile = new File(['x'], 'local-secret.txt', { type: 'text/plain' }); + const dataTransfer = { types: ['Files'], files: [localFile] }; + fireEvent.dragOver(container, { dataTransfer }); + fireEvent.drop(container, { dataTransfer }); + + expect(terminal.input).not.toHaveBeenCalled(); + }); + test('a drop where every file resolves to no disk path writes nothing (clipboard blobs)', async () => { const { bridge, terminal } = makeBridge({ ok: true, ptyId: 'pty-1' }); // Electron `webUtils.getPathForFile` returns '' for a File with no disk @@ -1028,6 +1066,20 @@ describe('TerminalPanel', () => { expect(onClose).toHaveBeenCalledTimes(1); }); + test('reports an SSH outage without mislabeling it as a consent refusal', async () => { + const { bridge, terminal } = makeBridge({ ok: false, reason: 'remote-unavailable' }); + render(); + + await waitFor(() => + expect(document.querySelector('[data-terminal-status="remote-unavailable"]')).not.toBeNull(), + ); + const alert = await screen.findByRole('alert'); + expect(alert.textContent).toMatch(/SSH machine is unavailable/i); + expect(alert.textContent).not.toMatch(/isn't enabled/i); + expect(lastTerm?.focus).not.toHaveBeenCalled(); + expect(terminal.onData).not.toHaveBeenCalled(); + }); + test('omits the "Close terminal" button when no onClose is provided', async () => { const { bridge } = makeBridge({ ok: false, reason: 'not-consented' }); render(); @@ -1470,6 +1522,62 @@ describe('TerminalPanel', () => { await waitFor(() => expect(revealExternal).toHaveBeenCalledWith('/tmp/out/report.pdf')); }); + test('remote absolute paths resolve from the remote root and assets stay in-app', async () => { + const { bridge, openAsset, revealAsset, checkTargetExists } = makeRemoteBridge(); + setPageListCache({ + pages: new Set(), + folderPaths: new Set(), + assetPaths: new Set(['data/x.csv']), + pagesBySlug: new Map(), + }); + render(); + await waitFor(() => expect(lastTerm?.linkProvider).toBeTruthy()); + const term = lastTerm as MockTerminal; + term.lineText = 'wrote /srv/knowledge/data/x.csv'; + + const [link] = await provide(term); + expect(link?.text).toBe('/srv/knowledge/data/x.csv'); + // Snapshot hit: the absolute path only resolves when the provider uses + // `/srv/knowledge` as its root; no local-filesystem IPC probe is needed. + expect(checkTargetExists).not.toHaveBeenCalled(); + + window.location.hash = ''; + link?.activate({} as MouseEvent, link.text); + expect(window.location.hash).toBe(hashFromAssetPath('data/x.csv')); + expect(openAsset).not.toHaveBeenCalled(); + expect(revealAsset).not.toHaveBeenCalled(); + }); + + test('uncached remote targets probe with the opaque SSH key, never a local-looking remote path', async () => { + const { bridge, checkTargetExists } = makeRemoteBridge(); + render(); + await waitFor(() => expect(lastTerm?.linkProvider).toBeTruthy()); + const term = lastTerm as MockTerminal; + term.lineText = 'edited /srv/knowledge/notes/a.md'; + + await provide(term); + expect(checkTargetExists).toHaveBeenCalledWith({ + projectPath: 'ssh:machine-1:%2Fsrv%2Fknowledge', + kind: 'doc', + path: 'notes/a.md', + }); + expect(checkTargetExists).not.toHaveBeenCalledWith( + expect.objectContaining({ projectPath: '/srv/knowledge' }), + ); + }); + + test('remote terminals do not link absolute paths outside the remote project', async () => { + const { bridge, revealExternal, checkTargetExists } = makeRemoteBridge(); + render(); + await waitFor(() => expect(lastTerm?.linkProvider).toBeTruthy()); + const term = lastTerm as MockTerminal; + term.lineText = 'built /tmp/out/report.pdf'; + + expect(await provide(term)).toEqual([]); + expect(checkTargetExists).not.toHaveBeenCalled(); + expect(revealExternal).not.toHaveBeenCalled(); + }); + test('defers URL clicks to a mouse-tracking TUI (no double-open with claude)', async () => { const { bridge, openExternal } = makeBridge({ ok: true, ptyId: 'pty-1' }); render(); diff --git a/packages/app/src/components/TerminalPanel.tsx b/packages/app/src/components/TerminalPanel.tsx index 07506bd39..38eaecaf8 100644 --- a/packages/app/src/components/TerminalPanel.tsx +++ b/packages/app/src/components/TerminalPanel.tsx @@ -17,7 +17,12 @@ import { ConfigContext } from '@/lib/config-context'; import type { ClaudeReadiness, OkDesktopBridge } from '@/lib/desktop-bridge-types'; import { cn } from '@/lib/utils'; import { getPageListCache } from '../editor/page-list-cache'; -import { filePathToDocName, hashFromDocName, hashFromFolderPath } from '../lib/doc-hash'; +import { + filePathToDocName, + hashFromAssetPath, + hashFromDocName, + hashFromFolderPath, +} from '../lib/doc-hash'; import { ClaudeReadinessBanner } from './ClaudeReadinessBanner'; import type { TerminalLaunchIntent } from './EditorPane'; import { filesFromExternalDrop, isExternalFileDrag } from './file-tree-adapter'; @@ -151,7 +156,13 @@ export function TerminalPanel({ ); } -type SessionStatus = 'starting' | 'running' | 'no-project' | 'not-consented' | 'exited'; +type SessionStatus = + | 'starting' + | 'running' + | 'no-project' + | 'not-consented' + | 'remote-unavailable' + | 'exited'; interface TerminalSessionProps { readonly bridge: OkDesktopBridge; @@ -201,6 +212,7 @@ function TerminalSession({ // Set when a codex/cursor/opencode launch probed `not-found` on PATH — drives // the missing-CLI banner. Claude uses its own readiness banner instead. const [missingCli, setMissingCli] = useState(null); + const isRemoteProject = bridge.config.remote != null; // Auto-approve OK's own tools for the baked launch (user-scope preference, // default on). Read the config context nullably (`use`, not `useConfigContext`) @@ -243,6 +255,10 @@ function TerminalSession({ const screenReaderModeAtMount = bridge.config.e2eSmoke === true || (bridge.accessibility?.isScreenReaderActive() ?? true); const recentOpen = createRecentOpenGuard(); + // Desktop state identifies an SSH project with an opaque `ssh:...` key. + // Files printed by the remote PTY are rooted at the canonical remote path, + // so link resolution and existence probes must use that filesystem root. + const projectFilesystemRoot = bridge.config.remote?.path ?? bridge.config.projectPath; const openUrl = (uri: string) => { // Defer while a full-screen TUI owns the mouse: the click is delivered to // the app as a mouse report, so the terminal must not also open the link. @@ -324,6 +340,12 @@ function TerminalSession({ .catch((err) => console.warn('[terminal] revealExternal failed:', err)); return; case 'asset': + if (isRemoteProject) { + // The asset already has an in-app preview backed by the tunneled + // project server. Never hand a remote path to the local OS opener. + window.location.hash = hashFromAssetPath(target.relPath); + return; + } void bridge.shell .openAsset(target.relPath) .then((result) => { @@ -350,7 +372,8 @@ function TerminalSession({ }; linkProviderDisposable = term.registerLinkProvider( createTerminalFileLinkProvider({ - projectPath: bridge.config.projectPath, + projectPath: projectFilesystemRoot, + allowExternalPaths: !isRemoteProject, readLogicalLine: (bufferLineNumber) => { const buf = term.buffer.active; const idx = bufferLineNumber - 1; @@ -376,6 +399,10 @@ function TerminalSession({ getSnapshot: getPageListCache, checkTargetExists: (kind, path) => bridge.project.checkTargetExists({ + // Main uses the opaque SSH project key to recognize that it must + // not stat this path on the local Mac. Resolution still uses the + // canonical remote root above; uncached remote targets fail safe + // as `unreadable` until a remote-scoped probe exists. projectPath: bridge.config.projectPath, kind, path, @@ -819,7 +846,7 @@ function TerminalSession({ // Main refused the spawn. Surface why via an explicit notice rather // than leaving the bare (focused) canvas — the two reasons are distinct // and recoverable in different ways. Do NOT focus the dead canvas. - setStatus(result.reason === 'not-consented' ? 'not-consented' : 'no-project'); + setStatus(result.reason); return; } @@ -877,7 +904,7 @@ function TerminalSession({ // adoptPtyId is stable for a session instance (a restart remounts via the // parent key rather than changing it), so listing it never re-runs this // mount/adopt effect — it only satisfies the exhaustive-deps check. - }, [bridge, adoptPtyId, launch]); + }, [bridge, adoptPtyId, launch, isRemoteProject]); // Re-skin the live terminal when the app theme changes. Mutating // `term.options.theme` re-paints in place, so an open session follows @@ -922,6 +949,10 @@ function TerminalSession({ function onDrop(event: DragEvent) { if (!isExternalFileDrag(event)) return; event.preventDefault(); + // Finder paths belong to the local Mac and are meaningless to a shell on + // an SSH host. Still prevent Chromium's file navigation, but never write + // the local path into a remote PTY. + if (isRemoteProject) return; const livePtyId = ptyIdRef.current; if (livePtyId === null) return; const paths = filesFromExternalDrop(event) @@ -950,7 +981,7 @@ function TerminalSession({ container.removeEventListener('dragover', onDragOver, { capture: true }); container.removeEventListener('drop', onDrop, { capture: true }); }; - }, [bridge]); + }, [bridge, isRemoteProject]); return ( // Column layout so the readiness banner is a strip ABOVE the terminal @@ -975,7 +1006,7 @@ function TerminalSession({ {status === 'exited' && exitInfo ? ( ) : null} - {status === 'no-project' || status === 'not-consented' ? ( + {status === 'no-project' || status === 'not-consented' || status === 'remote-unavailable' ? ( ) : null} diff --git a/packages/app/src/components/TerminalRefusalNotice.tsx b/packages/app/src/components/TerminalRefusalNotice.tsx index 91f5bd5f0..f709b4849 100644 --- a/packages/app/src/components/TerminalRefusalNotice.tsx +++ b/packages/app/src/components/TerminalRefusalNotice.tsx @@ -15,7 +15,7 @@ import { useLingui } from '@lingui/react/macro'; import { Button } from '@/components/ui/button'; interface TerminalRefusalNoticeProps { - readonly reason: 'no-project' | 'not-consented'; + readonly reason: 'no-project' | 'not-consented' | 'remote-unavailable'; /** Release focus / collapse the dock back to the editor. */ readonly onClose?: () => void; } @@ -26,7 +26,9 @@ export function TerminalRefusalNotice({ reason, onClose }: TerminalRefusalNotice const message = reason === 'not-consented' ? t`Terminal access isn't enabled for this project.` - : t`There's no project folder for this window, so a terminal can't start here.`; + : reason === 'remote-unavailable' + ? t`The SSH machine is unavailable, so the remote terminal couldn't start. Check the connection and try again.` + : t`There's no project folder for this window, so a terminal can't start here.`; return (
{ test('exports the hook + classifier + deps factory', async () => { @@ -50,3 +51,30 @@ describe('isElectronHostDefault — pure host classifier', () => { expect(isElectronHostDefault({ okDesktop: { shell: {} } })).toBe(true); }); }); + +describe('selectInstalledAgentProbeTransport', () => { + test('local Electron probes application protocols through IPC', async () => { + const { selectInstalledAgentProbeTransport } = await import('./useInstalledAgents'); + const desktop = { + config: { remote: null }, + } as unknown as Pick; + expect(selectInstalledAgentProbeTransport(desktop)).toBe('desktop-ipc'); + }); + + test('SSH Electron probes the remote project server through HTTP', async () => { + const { selectInstalledAgentProbeTransport } = await import('./useInstalledAgents'); + const desktop = { + config: { + remote: { + kind: 'ssh', + machineId: 'machine-1', + machineName: 'Build box', + path: '/srv/knowledge', + platform: 'linux', + pathSeparator: '/', + }, + }, + } as unknown as Pick; + expect(selectInstalledAgentProbeTransport(desktop)).toBe('project-http'); + }); +}); diff --git a/packages/app/src/components/handoff/useInstalledAgents.ts b/packages/app/src/components/handoff/useInstalledAgents.ts index 01778af4d..4f650b9a0 100644 --- a/packages/app/src/components/handoff/useInstalledAgents.ts +++ b/packages/app/src/components/handoff/useInstalledAgents.ts @@ -9,7 +9,8 @@ * dropdown re-renders live without being closed. * * Host wiring: - * - Electron (`window.okDesktop` populated): `detectProtocol` IPC per scheme. + * - Local Electron (`window.okDesktop` populated): `detectProtocol` IPC per scheme. + * - SSH-backed Electron: `GET /api/installed-agents` on the remote project server. * - Web (`window.okDesktop` undefined): single `GET /api/installed-agents`. * * Defense-in-depth: web-host Cursor is always `installed: false` regardless of @@ -33,6 +34,16 @@ import { } from '@/lib/handoff/install-detect'; // Side-effect import only — loads the `Window.okDesktop?` global augmentation. import '@/lib/desktop-bridge-types'; +import type { OkDesktopBridge } from '@/lib/desktop-bridge-types'; + +export type InstalledAgentProbeTransport = 'desktop-ipc' | 'project-http'; + +/** Pure classifier: application protocols are local-only; SSH probes the remote server. */ +export function selectInstalledAgentProbeTransport( + desktop: Pick | undefined, +): InstalledAgentProbeTransport { + return desktop && !desktop.config.remote ? 'desktop-ipc' : 'project-http'; +} /** * Pure host classifier — true when the Electron preload has populated @@ -54,7 +65,7 @@ export function isElectronHostDefault( */ export function defaultProbeDeps(): ProbeDeps { const bridge = typeof window !== 'undefined' ? window.okDesktop : undefined; - if (bridge) { + if (bridge && selectInstalledAgentProbeTransport(bridge) === 'desktop-ipc') { const detector = (scheme: string) => bridge.shell.detectProtocol(scheme); return { probe: (): Promise => probeViaElectron({ detectProtocol: detector }), @@ -62,6 +73,9 @@ export function defaultProbeDeps(): ProbeDeps { now: Date.now, }; } + // For an SSH-backed desktop window the always-installed fetch wrapper routes + // this relative request through the loopback tunnel, so install state reflects + // the remote host rather than applications installed on the local Mac. const fetchFn = globalThis.fetch.bind(globalThis); return { probe: (): Promise => probeViaFetch({ fetch: fetchFn }), diff --git a/packages/app/src/components/project-switcher-recents.ts b/packages/app/src/components/project-switcher-recents.ts index 5522492a3..2755dab06 100644 --- a/packages/app/src/components/project-switcher-recents.ts +++ b/packages/app/src/components/project-switcher-recents.ts @@ -13,6 +13,11 @@ import type { WorktreeSelectorModel } from '@inkeep/open-knowledge-core'; import type { RecentProjectEntry } from '@/lib/desktop-bridge-types'; +/** Human-facing path for local and SSH-backed recent rows. */ +export function recentProjectDisplayPath(project: RecentProjectEntry): string { + return project.remote ? `${project.remote.machineName} • ${project.remote.path}` : project.path; +} + export interface RecentRepoGroup { /** The main project row (synthesized from `mainRoot` when not itself a recent). */ readonly project: RecentProjectEntry; diff --git a/packages/app/src/components/settings/SettingsDialogShell.dom.test.tsx b/packages/app/src/components/settings/SettingsDialogShell.dom.test.tsx index bf661dd1d..285f1f42a 100644 --- a/packages/app/src/components/settings/SettingsDialogShell.dom.test.tsx +++ b/packages/app/src/components/settings/SettingsDialogShell.dom.test.tsx @@ -159,6 +159,7 @@ describe('SettingsDialogShell userBinding gating (Tier-3 mount)', () => { mockDesktopPresent = false; mockBodyMode = 'probe'; mockShowInstallSkill = true; + Reflect.deleteProperty(window, 'okDesktop'); consoleErrorSpy = spyOn(console, 'error').mockImplementation(() => {}); }); @@ -252,6 +253,33 @@ describe('SettingsDialogShell userBinding gating (Tier-3 mount)', () => { expect(screen.getByTestId('settings-sidebar-item-claude-desktop')).toBeTruthy(); }); + test('remote windows hide project-local AI tooling and config sharing but keep normal settings', () => { + Object.defineProperty(window, 'okDesktop', { + configurable: true, + value: { + config: { + remote: { + kind: 'ssh', + machineId: 'machine-1', + machineName: 'Build box', + path: '/srv/knowledge', + platform: 'linux', + pathSeparator: '/', + }, + }, + }, + }); + + render( {}} />); + + expect(screen.getByTestId('settings-sidebar-item-preferences')).toBeTruthy(); + expect(screen.getByTestId('settings-sidebar-item-search')).toBeTruthy(); + expect(screen.getByTestId('settings-sidebar-item-project-templates')).toBeTruthy(); + expect(screen.getByTestId('settings-sidebar-item-terminal')).toBeTruthy(); + expect(screen.queryByTestId('settings-sidebar-item-project-ai-tools')).toBeNull(); + expect(screen.queryByTestId('settings-sidebar-item-sharing')).toBeNull(); + }); + test('changes sections through the sidebar and resets to Preferences on each fresh open', async () => { const { rerender } = render( {}} />); diff --git a/packages/app/src/components/settings/SettingsDialogShell.tsx b/packages/app/src/components/settings/SettingsDialogShell.tsx index 46be0109a..e57bff9cc 100644 --- a/packages/app/src/components/settings/SettingsDialogShell.tsx +++ b/packages/app/src/components/settings/SettingsDialogShell.tsx @@ -96,6 +96,7 @@ export function SettingsDialogShell({ open, onOpenChange }: SettingsDialogShellP // The docked terminal is desktop-only (the real shell has no web host), so // its per-project revoke toggle only appears under the Electron preload. const isOkDesktopHost = typeof window !== 'undefined' && window.okDesktop != null; + const isRemoteProject = typeof window !== 'undefined' && window.okDesktop?.config.remote != null; const groups: SidebarGroup[] = [ { @@ -122,11 +123,13 @@ export function SettingsDialogShell({ open, onOpenChange }: SettingsDialogShellP ...(isOkDesktopHost ? [{ id: 'terminal', label: t`Terminal` }] : []), // Per-project MCP wiring + runtime skill — desktop-only because the // install actors live in the Electron main process (like Terminal). - ...(isOkDesktopHost ? [{ id: 'project-ai-tools', label: t`AI tools` }] : []), + ...(isOkDesktopHost && !isRemoteProject + ? [{ id: 'project-ai-tools', label: t`AI tools` }] + : []), { id: 'project-templates', label: t`Templates` }, { id: 'skills', label: t`Skills` }, { id: 'okignore', label: t`Ignore patterns` }, - { id: 'sharing', label: t`Config sharing` }, + ...(!isRemoteProject ? [{ id: 'sharing', label: t`Config sharing` }] : []), ], }, { diff --git a/packages/app/src/components/settings/TerminalSection.dom.test.tsx b/packages/app/src/components/settings/TerminalSection.dom.test.tsx index 1ac31b4f7..7f58c8094 100644 --- a/packages/app/src/components/settings/TerminalSection.dom.test.tsx +++ b/packages/app/src/components/settings/TerminalSection.dom.test.tsx @@ -169,6 +169,7 @@ type CodexReadiness = { onPath: string; okServerConfigured?: boolean }; function stubDesktopBridge(readiness: CodexReadiness | Error): void { (globalThis as unknown as { window: { okDesktop?: unknown } }).window.okDesktop = { + config: { remote: null }, terminal: { cliPreflight: async () => readiness instanceof Error ? Promise.reject(readiness) : readiness, @@ -176,6 +177,22 @@ function stubDesktopBridge(readiness: CodexReadiness | Error): void { }; } +function stubRemoteDesktopBridge(): void { + (globalThis as unknown as { window: { okDesktop?: unknown } }).window.okDesktop = { + config: { + remote: { + kind: 'ssh', + machineId: 'machine-1', + machineName: 'Build host', + path: '/srv/project', + platform: 'linux', + pathSeparator: '/', + }, + }, + terminal: { cliPreflight: async () => ({ onPath: 'present', okServerConfigured: false }) }, + }; +} + function codexNote(): HTMLElement | null { return screen.queryByTestId('settings-terminal-autoapprove-codex-note'); } @@ -234,3 +251,32 @@ describe("TerminalSection (codex-can't-honor note)", () => { expect(codexNote()).toBeNull(); }); }); + +describe('TerminalSection (remote project authority copy)', () => { + beforeEach(() => { + consentState = { enabled: true, synced: true }; + writerImpl = () => ({ ok: true }); + userConfig = { agents: { autoApproveOkTools: true } }; + userSynced = true; + userBinding = { patch: () => ({ ok: true }) }; + stubRemoteDesktopBridge(); + }); + afterEach(() => { + cleanup(); + (globalThis as unknown as { window: { okDesktop?: unknown } }).window.okDesktop = undefined; + }); + + test('names the SSH machine/path and does not offer local-only agent auto-approval', () => { + render(); + expect( + screen.getByText( + 'Run a real terminal docked inside OpenKnowledge on Build host, starting in /srv/project.', + ), + ).toBeTruthy(); + expect(screen.getByTestId('settings-terminal-body').textContent).toContain( + 'full access of your SSH account on Build host', + ); + expect(screen.queryByTestId('settings-terminal-autoapprove-toggle')).toBeNull(); + expect(codexNote()).toBeNull(); + }); +}); diff --git a/packages/app/src/components/settings/TerminalSection.tsx b/packages/app/src/components/settings/TerminalSection.tsx index f72aa5745..84ad91e08 100644 --- a/packages/app/src/components/settings/TerminalSection.tsx +++ b/packages/app/src/components/settings/TerminalSection.tsx @@ -22,6 +22,7 @@ export function TerminalSection() { const { enabled, synced } = useTerminalConsentState(); const writer = useTerminalEnabledWriter(); const isOn = enabled !== false; + const remote = typeof window !== 'undefined' ? (window.okDesktop?.config?.remote ?? null) : null; const { userConfig, userBinding, userSynced } = useConfigContext(); // Default on: only an explicit `false` reads as off (mirrors the launch-site @@ -36,7 +37,10 @@ export function TerminalSection() { const [codexNeedsInit, setCodexNeedsInit] = useState(false); useEffect(() => { const bridge = window.okDesktop; - if (!bridge) return; + // Remote agent launches intentionally do not inherit the local desktop's + // MCP auto-approval state. Do not probe or imply that this toggle can make + // a remote Codex launch pre-approved. + if (!bridge || bridge.config?.remote) return; let cancelled = false; bridge.terminal .cliPreflight('codex') @@ -88,7 +92,9 @@ export function TerminalSection() { {t`Terminal`}

- {t`Run a real terminal docked inside OpenKnowledge, starting in this project's folder.`} + {remote + ? t`Run a real terminal docked inside OpenKnowledge on ${remote.machineName}, starting in ${remote.path}.` + : t`Run a real terminal docked inside OpenKnowledge, starting in this project's folder.`}

@@ -98,9 +104,13 @@ export function TerminalSection() { {t`Enable terminal for this project`}

- {isOn - ? t`Commands run with the full access of your macOS user account on this machine. Turn this off to disable the shell.` - : t`A real shell is off for this project. Turning it on runs commands with the full access of your macOS user account.`} + {remote + ? isOn + ? t`Commands run with the full access of your SSH account on ${remote.machineName}. Turn this off to disable the remote shell.` + : t`A remote shell is off for this project. Turning it on runs commands with the full access of your SSH account on ${remote.machineName}.` + : isOn + ? t`Commands run with the full access of your macOS user account on this machine. Turn this off to disable the shell.` + : t`A real shell is off for this project. Turning it on runs commands with the full access of your macOS user account.`}

-
-
- -

- {t`Applies to all projects on this machine. Claude and Codex, started from the built-in terminal, auto-approve OpenKnowledge's read and write tools (Claude also auto-runs "ok open"). Deleting, moving, sharing, installing skills, other commands, and non-OpenKnowledge file edits still ask. Cursor, OpenCode, and Pi are unaffected. Best-effort per agent.`} -

- {autoApproveOn && codexNeedsInit ? ( + {remote === null ? ( +
+
+

- {t`Codex will still ask until you run "ok init" in a terminal for this project.`} + {t`Applies to all projects on this machine. Claude and Codex, started from the built-in terminal, auto-approve OpenKnowledge's read and write tools (Claude also auto-runs "ok open"). Deleting, moving, sharing, installing skills, other commands, and non-OpenKnowledge file edits still ask. Cursor, OpenCode, and Pi are unaffected. Best-effort per agent.`}

- ) : null} + {autoApproveOn && codexNeedsInit ? ( +

+ {t`Codex will still ask until you run "ok init" in a terminal for this project.`} +

+ ) : null} +
+
- -
+ ) : null} ); } diff --git a/packages/app/src/components/skill-actions.tsx b/packages/app/src/components/skill-actions.tsx index a06389e09..ffc475e35 100644 --- a/packages/app/src/components/skill-actions.tsx +++ b/packages/app/src/components/skill-actions.tsx @@ -288,7 +288,7 @@ export function SkillContextMenuItems({ return ( <> - {bridge && absolutePath ? ( + {bridge && !bridge.config.remote && absolutePath ? ( void bridge.shell.showItemInFolder(absolutePath)}> Reveal in Finder diff --git a/packages/app/src/components/terminal-link-provider.test.ts b/packages/app/src/components/terminal-link-provider.test.ts index 4044f3d58..ffc10fa9c 100644 --- a/packages/app/src/components/terminal-link-provider.test.ts +++ b/packages/app/src/components/terminal-link-provider.test.ts @@ -77,6 +77,17 @@ describe('createTerminalFileLinkProvider', () => { expect(activated[0]).toEqual({ kind: 'external', absPath: '/tmp/out/report.pdf' }); }); + test('suppresses out-of-project absolute paths when the host cannot reveal them', async () => { + const { provider, activated, checkTargetExists } = makeProvider({ + readLogicalLine: row('built /tmp/out/report.pdf'), + allowExternalPaths: false, + }); + + expect(await provide(provider, 1)).toBeUndefined(); + expect(checkTargetExists).not.toHaveBeenCalled(); + expect(activated).toEqual([]); + }); + test('does not link a path the probe reports missing', async () => { const { provider } = makeProvider({ readLogicalLine: row('missing gone/file.md'), diff --git a/packages/app/src/components/terminal-link-provider.ts b/packages/app/src/components/terminal-link-provider.ts index 0fcebe13a..f9ff14d41 100644 --- a/packages/app/src/components/terminal-link-provider.ts +++ b/packages/app/src/components/terminal-link-provider.ts @@ -54,6 +54,12 @@ export interface TerminalFileLinkProviderDeps { ) => Promise; /** Route a validated target (editor nav / OS open). */ readonly onActivate: (target: TerminalLinkTarget) => void; + /** + * Whether absolute paths outside `projectPath` should become optimistic + * links. Remote terminals disable this: desktop cannot safely reveal a path + * that exists on another machine through its local file manager. + */ + readonly allowExternalPaths?: boolean; /** Per-line detection/validation cap. Defaults to 10 (bounds probe work). */ readonly maxLinksPerLine?: number; } @@ -142,6 +148,7 @@ export function createTerminalFileLinkProvider(deps: TerminalFileLinkProviderDep let target: TerminalLinkTarget; if (resolved.kind === 'external') { + if (deps.allowExternalPaths === false) return null; // Out-of-project absolute path. Not existence-gated here: there is no // project-scoped probe for a path outside the project, so it links // optimistically and the reveal handler stats it (a missing path diff --git a/packages/app/src/hooks/use-editor-footer-identity.ts b/packages/app/src/hooks/use-editor-footer-identity.ts index dbecaa163..45a648235 100644 --- a/packages/app/src/hooks/use-editor-footer-identity.ts +++ b/packages/app/src/hooks/use-editor-footer-identity.ts @@ -10,6 +10,7 @@ import { useSidebar } from '@/components/ui/sidebar'; import { useCurrentBranch } from '@/hooks/use-current-branch'; import { extractFolderBasename } from '@/lib/path-utils'; +import { desktopProjectLocation } from '@/lib/remote-project-display'; import { useWorkspace } from '@/lib/use-workspace'; export interface EditorFooterIdentity { @@ -30,7 +31,9 @@ export function useEditorFooterIdentity(): EditorFooterIdentity | null { const projectName = desktopBridge?.config.projectName ?? (workspace ? extractFolderBasename(workspace.contentDir) || null : null); - const projectPath = desktopBridge?.config.projectPath ?? workspace?.contentDir ?? null; + const projectPath = desktopBridge + ? desktopProjectLocation(desktopBridge.config) + : (workspace?.contentDir ?? null); if (projectName === null && branch === null) return null; return { projectName, projectPath, branch }; diff --git a/packages/app/src/lib/desktop-bridge-types.ts b/packages/app/src/lib/desktop-bridge-types.ts index 09a11c87d..92bd2414f 100644 --- a/packages/app/src/lib/desktop-bridge-types.ts +++ b/packages/app/src/lib/desktop-bridge-types.ts @@ -24,7 +24,12 @@ import type { LocalOpOkInitResponse, OkFolderState, RecentProjectEntry, + RemoteDirectoryListing, + RemoteProjectInfo, ShareTargetStatusResponse, + SshConnectionTestResult, + SshMachine, + SshMachineDraft, TerminalCli, WorktreeCreateRequest, WorktreeCreateResult, @@ -32,6 +37,7 @@ import type { } from '@inkeep/open-knowledge-core'; export type { OkFolderState, RecentProjectEntry }; +export type OkSshMachine = SshMachine; /** Seed scaffolder shapes — structurally duplicated from * `@inkeep/open-knowledge-server`'s seed module. See core's desktop-bridge.ts @@ -142,6 +148,8 @@ export interface OkDesktopConfig { readonly apiOrigin: string; readonly projectPath: string; readonly projectName: string; + /** SSH metadata for a remote window; null for local/navigator windows. */ + readonly remote: RemoteProjectInfo | null; readonly mode: OkDesktopMode; /** * `true` only under the Electron smoke suite. The renderer reads it to use @@ -765,7 +773,10 @@ export type OkServerRestartOutcome = /** Result of `terminal.create`. Canonical JSDoc in `bridge-contract.ts`; mirrored verbatim (drift-tested). */ export type OkPtyCreateResult = | { readonly ok: true; readonly ptyId: string } - | { readonly ok: false; readonly reason: 'no-project' | 'not-consented' }; + | { + readonly ok: false; + readonly reason: 'no-project' | 'not-consented' | 'remote-unavailable'; + }; /** Entry of `terminal.list`. Canonical JSDoc in `bridge-contract.ts`; mirrored verbatim (drift-tested). */ export interface OkPtyListEntry { @@ -802,7 +813,7 @@ export interface OkPtyExit { */ export interface ClaudeReadiness { readonly claude: 'present' | 'not-found' | 'unknown'; - readonly mcp: 'wired' | 'needs-rewire'; + readonly mcp: 'wired' | 'needs-rewire' | 'unknown'; /** True when the project's own `open-knowledge` `.mcp.json` entry is verified * to be OK's canonical managed server (cli `isOwnManagedEntry`), so the docked * terminal may pre-approve it on Claude launch instead of re-showing Claude's @@ -989,6 +1000,15 @@ export interface OkDesktopBridge { clipboard: { writeText(text: string): Promise; }; + /** Saved-machine management and SSH-backed project opening. */ + remote: { + listMachines(): Promise; + saveMachine(machine: SshMachineDraft): Promise; + removeMachine(machineId: string): Promise; + testMachine(machineId: string): Promise; + listDirectories(request: { machineId: string; path: string }): Promise; + openProject(request: { machineId: string; path: string }): Promise; + }; project: { listRecent(): Promise; /** diff --git a/packages/app/src/lib/handoff/skill-installer.test.ts b/packages/app/src/lib/handoff/skill-installer.test.ts index 9fca70906..420c089ad 100644 --- a/packages/app/src/lib/handoff/skill-installer.test.ts +++ b/packages/app/src/lib/handoff/skill-installer.test.ts @@ -11,12 +11,42 @@ */ import { describe, expect, mock, test } from 'bun:test'; +import type { OkDesktopBridge } from '@/lib/desktop-bridge-types'; import { type ElectronSkillBridge, electronSkillInstaller, httpSkillInstaller, + selectSkillInstallerTransport, } from './skill-installer'; +function desktopHost(remote: boolean): Pick { + return { + config: { + remote: remote + ? { + kind: 'ssh', + machineId: 'machine-1', + machineName: 'Build box', + path: '/srv/knowledge', + platform: 'linux', + pathSeparator: '/', + } + : null, + }, + skill: {}, + } as unknown as Pick; +} + +describe('selectSkillInstallerTransport', () => { + test('local desktop builds through IPC', () => { + expect(selectSkillInstallerTransport(desktopHost(false))).toBe('desktop-ipc'); + }); + + test('SSH desktop builds through tunneled project HTTP', () => { + expect(selectSkillInstallerTransport(desktopHost(true))).toBe('project-http'); + }); +}); + describe('electronSkillInstaller', () => { test('bridge ok: returns ok with path', async () => { const bridge: ElectronSkillBridge = { diff --git a/packages/app/src/lib/handoff/skill-installer.ts b/packages/app/src/lib/handoff/skill-installer.ts index 43f449b4d..b3db73e2e 100644 --- a/packages/app/src/lib/handoff/skill-installer.ts +++ b/packages/app/src/lib/handoff/skill-installer.ts @@ -46,6 +46,15 @@ export interface SkillInstaller { install(opts?: SkillInstallOptions): Promise; } +export type SkillInstallerTransport = 'desktop-ipc' | 'project-http'; + +/** Pure transport classifier: remote builds must happen beside the remote project. */ +export function selectSkillInstallerTransport( + desktop: Pick | undefined, +): SkillInstallerTransport { + return desktop?.skill && !desktop.config.remote ? 'desktop-ipc' : 'project-http'; +} + /** * Bridge subset this module consumes. Derived from the canonical desktop-bridge * type so the contract has a single source of truth. @@ -181,14 +190,17 @@ export function httpSkillInstaller(opts: HttpSkillInstallerOptions = {}): SkillI } /** - * Pick the right installer for the current host. Returns `null` only when - * no installer can be constructed (server-side rendering / non-browser - * environment). Browser tabs always get `httpSkillInstaller` — the local - * Hocuspocus server is reachable via same-origin fetch. + * Pick the right installer for the current project host. SSH-backed desktop + * windows use HTTP so the skill is built beside the remote project; the + * renderer's fetch wrapper routes the relative request through the tunnel. + * Returns `null` only when no installer can be constructed (SSR). */ export function defaultSkillInstaller(): SkillInstaller | null { if (typeof window === 'undefined') return null; - const electronBridge = window.okDesktop?.skill; + const electronBridge = + selectSkillInstallerTransport(window.okDesktop) === 'desktop-ipc' + ? window.okDesktop?.skill + : undefined; if (electronBridge) return electronSkillInstaller(electronBridge); return httpSkillInstaller(); } diff --git a/packages/app/src/lib/remote-project-display.test.ts b/packages/app/src/lib/remote-project-display.test.ts new file mode 100644 index 000000000..e7685f897 --- /dev/null +++ b/packages/app/src/lib/remote-project-display.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, test } from 'bun:test'; +import { desktopProjectLocation } from './remote-project-display'; + +describe('desktopProjectLocation', () => { + test('keeps a local project filesystem path', () => { + expect(desktopProjectLocation({ projectPath: '/Users/me/project' })).toBe('/Users/me/project'); + }); + + test('shows the SSH machine and canonical remote path instead of the opaque project key', () => { + const opaqueKey = 'ssh:machine-1:%2Fsrv%2Fknowledge'; + const location = desktopProjectLocation({ + projectPath: opaqueKey, + remote: { + kind: 'ssh', + machineId: 'machine-1', + machineName: 'Build box', + path: '/srv/knowledge', + platform: 'linux', + pathSeparator: '/', + }, + }); + + expect(location).toBe('Build box • /srv/knowledge'); + expect(location).not.toContain(opaqueKey); + }); +}); diff --git a/packages/app/src/lib/remote-project-display.ts b/packages/app/src/lib/remote-project-display.ts new file mode 100644 index 000000000..9eebd9681 --- /dev/null +++ b/packages/app/src/lib/remote-project-display.ts @@ -0,0 +1,15 @@ +import type { OkDesktopConfig } from '@/lib/desktop-bridge-types'; + +/** + * Human-readable filesystem location for desktop project chrome. + * + * A remote window's `projectPath` is an opaque machine-scoped identity used by + * desktop state (`ssh::`), not a path a user can act + * on. Tooltips must therefore use the frozen SSH metadata instead. + */ +export function desktopProjectLocation( + config: Pick, +): string { + const remote = config.remote; + return remote ? `${remote.machineName} • ${remote.path}` : config.projectPath; +} diff --git a/packages/app/src/lib/restart-collab-server.test.ts b/packages/app/src/lib/restart-collab-server.test.ts index 40523876d..a520df12f 100644 --- a/packages/app/src/lib/restart-collab-server.test.ts +++ b/packages/app/src/lib/restart-collab-server.test.ts @@ -14,10 +14,19 @@ describe('restartServerFailureMessage', () => { "Couldn't restart the server. Try `ok start` in this folder.", ); }); + + test('SSH projects point at remote connection and prerequisite recovery', () => { + expect(restartServerFailureMessage('other', true)).toBe( + "Couldn't reconnect to the SSH project. Check the SSH connection and the remote OpenKnowledge prerequisites, then try again.", + ); + }); }); describe('restartCollabServer', () => { - function makeBridge(outcome: Awaited>): { + function makeBridge( + outcome: Awaited>, + remote = false, + ): { bridge: Pick; restartServer: ReturnType; } { @@ -25,7 +34,21 @@ describe('restartCollabServer', () => { return { bridge: { restartServer, - config: { projectPath: '/tmp/proj' }, + config: { + projectPath: remote ? 'ssh:machine-1:%2Fsrv%2Fwiki' : '/tmp/proj', + ...(remote + ? { + remote: { + kind: 'ssh' as const, + machineId: 'machine-1', + machineName: 'Build box', + path: '/srv/wiki', + platform: 'linux', + pathSeparator: '/' as const, + }, + } + : {}), + }, } as unknown as Pick, restartServer, }; @@ -49,4 +72,14 @@ describe('restartCollabServer', () => { const result = await restartCollabServer(bridge); expect(result).toEqual({ ok: false, message: restartServerFailureMessage('other') }); }); + + test('maps a remote failure to SSH recovery guidance', async () => { + const { bridge, restartServer } = makeBridge({ ok: false, reason: 'other' }, true); + const result = await restartCollabServer(bridge); + expect(restartServer).toHaveBeenCalledWith('ssh:machine-1:%2Fsrv%2Fwiki'); + expect(result).toEqual({ + ok: false, + message: restartServerFailureMessage('other', true), + }); + }); }); diff --git a/packages/app/src/lib/restart-collab-server.ts b/packages/app/src/lib/restart-collab-server.ts index 359a3227d..b96be003c 100644 --- a/packages/app/src/lib/restart-collab-server.ts +++ b/packages/app/src/lib/restart-collab-server.ts @@ -13,7 +13,13 @@ import type { OkDesktopBridge } from '@/lib/desktop-bridge-types'; */ /** Map a failed `restartServer` outcome reason to a user-facing message. */ -export function restartServerFailureMessage(reason: 'eperm' | 'other'): string { +export function restartServerFailureMessage( + reason: 'eperm' | 'other', + remoteProject = false, +): string { + if (remoteProject) { + return t`Couldn't reconnect to the SSH project. Check the SSH connection and the remote OpenKnowledge prerequisites, then try again.`; + } return reason === 'eperm' ? t`Couldn't restart the server — another process owns it. Quit other OpenKnowledge windows for this project, then try again.` : t`Couldn't restart the server. Try \`ok start\` in this folder.`; @@ -32,5 +38,8 @@ export async function restartCollabServer( ): Promise<{ ok: true } | { ok: false; message: string }> { const outcome = await bridge.restartServer(bridge.config.projectPath); if (outcome.ok) return { ok: true }; - return { ok: false, message: restartServerFailureMessage(outcome.reason) }; + return { + ok: false, + message: restartServerFailureMessage(outcome.reason, bridge.config.remote != null), + }; } diff --git a/packages/app/src/lib/seed-client.test.ts b/packages/app/src/lib/seed-client.test.ts new file mode 100644 index 000000000..461399410 --- /dev/null +++ b/packages/app/src/lib/seed-client.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, test } from 'bun:test'; +import type { OkDesktopBridge } from '@/lib/desktop-bridge-types'; +import { selectSeedTransport } from './seed-client'; + +function desktop(remote: boolean): Pick { + return { + config: { + remote: remote + ? { + kind: 'ssh', + machineId: 'machine-1', + machineName: 'Build box', + path: '/srv/knowledge', + platform: 'linux', + pathSeparator: '/', + } + : null, + }, + seed: {}, + } as unknown as Pick; +} + +describe('selectSeedTransport', () => { + test('local desktop uses main-process IPC', () => { + expect(selectSeedTransport(desktop(false))).toBe('desktop-ipc'); + }); + + test('SSH desktop uses tunneled project HTTP', () => { + expect(selectSeedTransport(desktop(true))).toBe('project-http'); + }); + + test('web uses project HTTP', () => { + expect(selectSeedTransport(undefined)).toBe('project-http'); + }); +}); diff --git a/packages/app/src/lib/seed-client.ts b/packages/app/src/lib/seed-client.ts index fac6c14e5..f09375a48 100644 --- a/packages/app/src/lib/seed-client.ts +++ b/packages/app/src/lib/seed-client.ts @@ -1,5 +1,6 @@ import { ProblemDetailsSchema } from '@inkeep/open-knowledge-core'; import type { + OkDesktopBridge, OkPackId, OkScaffoldApplyResult, OkScaffoldPlan, @@ -63,10 +64,21 @@ interface SeedClientShape { listPacks: () => Promise; } +export type SeedTransport = 'desktop-ipc' | 'project-http'; + +/** Pure transport classifier used by the runtime adapter and regression tests. */ +export function selectSeedTransport( + desktop: Pick | undefined, +): SeedTransport { + return desktop?.seed && !desktop.config.remote ? 'desktop-ipc' : 'project-http'; +} + /** * Runtime adapter that returns the right transport for plan/apply/list-packs — - * Electron IPC when the desktop bridge is populated, otherwise HTTP fetch to - * the Hocuspocus `/api/seed/*` endpoints. Either path hits the same underlying + * Electron IPC for local desktop projects, otherwise HTTP fetch to the + * Hocuspocus `/api/seed/*` endpoints. SSH-backed desktop projects deliberately + * use HTTP so the operation runs beside the remote filesystem rather than in + * desktop main. Either path hits the same underlying * functions in `@inkeep/open-knowledge-server`. The HTTP path emits flat * `{plan}` / `{result}` / `{packs}` on success and RFC 9457 problem+json on * error; this adapter translates either back to the in-process discriminated @@ -74,7 +86,7 @@ interface SeedClientShape { */ export function seedClient(): SeedClientShape { const okDesktop = typeof window !== 'undefined' ? window.okDesktop : undefined; - if (okDesktop?.seed) { + if (okDesktop?.seed && selectSeedTransport(okDesktop) === 'desktop-ipc') { const desktopApply = okDesktop.seed.apply; return { plan: okDesktop.seed.plan, diff --git a/packages/app/src/lib/use-workspace.test.ts b/packages/app/src/lib/use-workspace.test.ts index 597e1ff72..faddd86bb 100644 --- a/packages/app/src/lib/use-workspace.test.ts +++ b/packages/app/src/lib/use-workspace.test.ts @@ -24,6 +24,26 @@ describe('resolveSyncWorkspace', () => { }); }); + test('SSH window uses the remote path and separator instead of the desktop platform', () => { + const result = resolveSyncWorkspace({ + okDesktop: { + platform: 'darwin', + config: { + projectPath: 'ssh:machine:key', + remote: { + kind: 'ssh', + machineId: 'machine', + machineName: 'Linux box', + path: '/home/dev/notes', + platform: 'linux', + pathSeparator: '/', + }, + }, + }, + } as unknown as Window); + expect(result).toEqual({ contentDir: '/home/dev/notes', pathSeparator: '/' }); + }); + test('Electron Windows: backslash separator', () => { const windowLike = { okDesktop: { diff --git a/packages/app/src/lib/use-workspace.ts b/packages/app/src/lib/use-workspace.ts index ac42c794e..65ce42368 100644 --- a/packages/app/src/lib/use-workspace.ts +++ b/packages/app/src/lib/use-workspace.ts @@ -64,6 +64,13 @@ export function resolveSyncWorkspace( if (!windowLike) return null; const okDesktop = windowLike.okDesktop; if (!okDesktop) return null; + const remote = okDesktop.config.remote; + if (remote) { + return { + contentDir: remote.path, + pathSeparator: remote.pathSeparator, + }; + } return { contentDir: okDesktop.config.projectPath, pathSeparator: okDesktop.platform === 'win32' ? '\\' : '/', diff --git a/packages/app/src/locales/en/messages.json b/packages/app/src/locales/en/messages.json index a4727578a..1ae6010ff 100644 --- a/packages/app/src/locales/en/messages.json +++ b/packages/app/src/locales/en/messages.json @@ -80,12 +80,15 @@ "0yL--g": ["Review conflicts"], "10g8I_": ["Turn off Only markdown files"], "12F9WL": ["(no id)"], + "15OSDO": ["SSH"], "16D5Zj": ["Are you sure you want to delete ", ["itemName"], "? This action cannot be undone."], + "16NxA7": ["Open project"], "1AumWo": ["Push failed — check the server logs for details."], "1EcCom": [["keyName"], " value"], "1FLarN": ["Describe what you want to create to continue"], "1GhYxC": ["of ", ["totalPages"]], "1HBhDS": ["Checking out files"], + "1Lcfdy": ["No folders in this location."], "1MGRQl": ["Terminal sessions"], "1QfxQT": ["Dismiss"], "1RluQD": ["Show replace"], @@ -117,6 +120,9 @@ "2QM24L": ["All tags"], "2RBzMG": ["Network error — check your connection and try again"], "2XQ8Ww": ["Open preview when agent edits"], + "2Xcoe7": [ + "This removes the machine and its saved recent-project and session metadata from OpenKnowledge." + ], "2YOwme": ["Restarted — now running v", ["appRuntime"], "."], "2YZwPB": ["Forward-links response did not match expected shape."], "2bVHbB": ["Available in every project on this computer."], @@ -137,6 +143,7 @@ "322lyb": ["Highlight tips, warnings, and notes."], "34qyH5": ["Could not reach server"], "36WXSC": ["Couldn't delete template: ", ["error"]], + "36ZUr2": ["Remove the saved connection for ", ["0"], "?"], "36aWJD": ["Join us on Discord"], "379t2h": ["Unified diff"], "38foVZ": ["Loading diff"], @@ -164,6 +171,7 @@ "3a_Bd7": ["Open source doc: ", ["mirrorSrc"]], "3aeN7X": ["All files"], "3cOo2C": ["Create a folder from the current document or folder context."], + "3d4aXS": ["Failed to load SSH machines."], "3ePd3I": ["What's new"], "3h5zMp": ["Something went wrong loading the settings panel."], "3iyGWF": ["Open recent project"], @@ -207,10 +215,12 @@ "4c_a6S": ["Nothing set yet."], "4g19V6": ["Failed to update property"], "4hJhzz": ["Table"], + "4iwBH3": ["Work with a project on another machine."], "4jDd0j": ["Anyone can see"], "4mLBx9": ["Subscriptions aren't available right now."], "4niEzY": ["Sandboxed HTML embed — write HTML, see the rendered preview live."], "4nj6n8": ["/api/config returned HTTP ", ["code"]], + "4pSxwB": ["Go"], "4rjwLG": ["Create with ", ["0"], " CLI"], "4sKwf6": ["File already exists"], "4uNLmk": ["Pulling"], @@ -245,9 +255,18 @@ ["path"], ". Agents that reference this template by name will fail until it's recreated or replaced by one in a parent folder." ], + "5NIAwW": ["Check that the path exists and that your SSH account can read it."], "5OTgC7": ["Open and focus the bottom Ask AI prompt composer."], "5OqaJi": ["Enter a project name"], "5QGTaA": ["Could not copy relative path"], + "5QRgp0": [ + "The SSH machine is unavailable, so the remote terminal couldn't start. Check the connection and try again." + ], + "5Srnlh": [ + "Commands run with the full access of your SSH account on ", + ["0"], + ". Turn this off to disable the remote shell." + ], "5SwVv1": ["Not installed"], "5Tk8Fb": ["Loading files"], "5WDhAk": ["Link to a page or external URL."], @@ -342,6 +361,8 @@ "<0>ok won't run in external terminals until you add it later from the File menu. OpenKnowledge's built-in terminal and AI tools keep working." ], "7PzzBU": ["User"], + "7U78Tk": ["Connection successful."], + "7XZDcO": ["Save machine"], "7YRrJ9": [ "Available in the OpenKnowledge desktop app. From a terminal, use<0> ok config-sharing status / <1>share / <2>unshare." ], @@ -358,6 +379,7 @@ "7uBlRT": ["Copy the focused file-tree item so Paste can duplicate it."], "80c4-P": ["Code copied to clipboard"], "81H_23": ["Add properties"], + "82_cHI": ["Could not browse ", ["requestedPath"], " on ", ["machineName"], ". ", ["detail"]], "881Uwh": ["Delete selected table"], "89TtiD": [ "New file from template \"", @@ -391,6 +413,7 @@ "8fRyLo": ["Invalid patch payload"], "8fc_3j": ["Protected branch — cannot push"], "8m8diM": ["No location selected. Use Browse to choose a folder."], + "8mIb7u": ["Open remote project"], "8tjQCz": ["Explore"], "8uAWix": [ "This template lives in a parent folder — deleting it affects every folder beneath it that doesn't define its own version." @@ -399,6 +422,11 @@ "92rbk2": ["Mirror loading <0>", ["mirrorRef"], ""], "96G6Re": ["New folder"], "96w9hU": ["Something went wrong opening this file (HTTP ", ["status"], ")."], + "97AViW": [ + "A remote shell is off for this project. Turning it on runs commands with the full access of your SSH account on ", + ["0"], + "." + ], "99eR72": ["Visual editor table selection"], "9Bd18D": ["Failed to load hub pages"], "9NJWgv": ["Backlinks"], @@ -409,6 +437,7 @@ "9V0W-R": [ "Adds the OpenKnowledge skill to the <0>Claude Desktop App so it's available in Chat and Cowork sessions." ], + "9W2ANt": ["Loading SSH machines..."], "9bCiQ8": ["No pages are missing incoming graph links."], "9clcD5": ["Open documents tagged #", ["chip"]], "9dw5bW": ["Create with ", ["0"]], @@ -433,6 +462,7 @@ "9sBe85": ["Follow us on"], "9tJX8r": ["By meaning"], "9zb2WA": ["Connecting"], + "A10lA2": ["Loading folders..."], "A1taO8": ["Search"], "A50vr3": ["Use lowercase letters, digits, and <0>- only."], "A5CYnd": ["Broken link"], @@ -444,6 +474,7 @@ ], "AB4qw9": ["New terminal"], "AE7BY3": ["The terminal session ended."], + "AHveU-": ["Delete permanently"], "AIn2Yv": [["displayName"], " CLI, ", ["hint"]], "AItsE5": ["Open it there"], "AKxFtn": [["openedWorktreeCount", "plural", { "one": ["worktree"], "other": ["worktrees"] }]], @@ -520,6 +551,9 @@ "CCTop_": ["Recent"], "CFnavk": ["This project isn't a git repository, so worktrees aren't available."], "CKyk7Q": ["Go back"], + "CLut6v": [ + "Testing also installs or updates matching OpenKnowledge support in the SSH user's home directory." + ], "COr1Y7": [ "Pick <0>openknowledge.skill from your <1>Downloads folder. It enables automatically." ], @@ -577,6 +611,10 @@ ], "DhLHEb": ["Failed to load activity"], "DiFKSL": ["Counting markdown files"], + "DkVVq6": ["Open local folder"], + "Dmvx5e": [ + "Couldn't reconnect to the SSH project. Check the SSH connection and the remote OpenKnowledge prerequisites, then try again." + ], "Dnn2XG": ["Automatic"], "DrYM2Z": ["Sync disabled"], "DskIkK": ["Sign-in stream ended without confirmation — please try again"], @@ -600,6 +638,7 @@ ], "E7BhZn": ["Blank note"], "E9bKSA": ["Creating"], + "EAcqqW": ["Connection failed."], "EBVbtP": ["Could not duplicate item"], "EDl9kS": ["Subscribe"], "EDm3wJ": ["Loading diff renderer"], @@ -705,6 +744,7 @@ "GF51cw": [ "Give the AI tools you use access to read and update your projects. Pick what to set up below, and change it anytime." ], + "GFRtjt": ["Opening remote project..."], "GGsM4y": ["Selected in graph"], "GHsJb8": ["Show .ok folders"], "GJH-AT": [ @@ -771,6 +811,9 @@ "I1gRhX": ["Failed to rename"], "I5685_": ["Try a prompt"], "I5AfhE": ["Sync status unavailable — server unreachable."], + "I6NZOL": [ + "OpenKnowledge uses your system SSH config and SSH agent. Passwords and private keys are never stored." + ], "I7Exsw": ["Follow us on X"], "IA1wNb": ["The Claude Desktop App didn't open the file. Is it installed? (", ["msg"], ")"], "IA6JW2": ["Detecting"], @@ -811,6 +854,7 @@ "JLzQi_": ["Close ", ["accessibleLabel"]], "JM_aq-": ["Use an https:// URL — http:// is only allowed for localhost."], "JOcPqH": ["Bring a remote repository onto this machine."], + "JVR1YW": ["Machine name"], "JWsZlx": ["Open a project to edit."], "JZ8cO6": ["Light, dark, or follow the OS."], "Jejur6": ["Couldn't load document"], @@ -844,6 +888,7 @@ "KPW5WC": ["moved template"], "KQGZuq": ["Reused if it already exists, created if not."], "KRJ6xh": ["No tags yet. Continue typing to create one."], + "KUP3Yh": ["Failed to open the remote project."], "KWL_Ih": ["Blog post"], "KX1MZR": ["Your repositories"], "KYSocE": ["Research the extinction of flightless birds"], @@ -970,10 +1015,12 @@ "N55YNr": [ "Choose whether this project's OpenKnowledge setup, including its AI-tool connections, is saved with the project so teammates get it too, or kept only on your computer." ], + "N5c59j": ["Connect to an SSH machine and open a project directly from its filesystem."], "NEzPEh": ["Create a new branch, or check out an existing one, in its own window."], "NF_Wcg": ["Show skills section"], "NIWAux": ["No docs registered under #", ["tagDocsName"], "."], "NJyQi_": ["Branch ", ["shareBranch"], " no longer exists on the remote."], + "NKJBO1": ["Failed to save the SSH machine."], "NKkZXl": ["Initialize and open"], "NNJsn7": [ "One more step: enable it in Cursor → Settings → Tools & MCP (Cursor leaves project servers off until you turn them on)." @@ -984,6 +1031,7 @@ "NRSLBe": [["diffMin"], "m ago"], "NSnIUP": ["Two-page (even)"], "NUr4kz": ["A reusable starting point for new documents in this folder."], + "NVwtTv": ["Add SSH machine"], "NYUhy5": ["Subfolder name"], "N_7kkF": [ "Read through this codebase and draft a technical spec for the most complex module: an overview, the architecture, key files, and open questions, all linked from a specs index page." @@ -1055,6 +1103,13 @@ "P6gXB-": ["Replace ", ["keyName"]], "P6lAT3": ["Folder target: ", ["linkTitle"], ". Click to open the overview."], "P8yDjG": ["Name can’t be empty, \".\", \"..\", or contain / or \\"], + "PAbaOj": [ + "Run a real terminal docked inside OpenKnowledge on ", + ["0"], + ", starting in ", + ["1"], + "." + ], "PB4upl": ["Find previous match"], "PBwmDJ": ["Searching by meaning"], "PBxg_E": ["Not now"], @@ -1127,6 +1182,9 @@ "R9IDFz": ["Renames the folder on disk and the id agents use to invoke it."], "RDjuBN": ["Setup"], "RDrXh4": ["Configure user, project, and integration settings."], + "RFK-la": [ + "The remote macOS or Linux machine needs Node.js 24 or newer and Git 2.31.0 or newer in a POSIX-compatible login shell. OpenKnowledge installs its remote support in your SSH home directory automatically." + ], "RG4XHV": ["Copy ", ["0"], " prompt"], "RGCCrg": ["Copy share link"], "RGkNeJ": ["Initialize a starter pack"], @@ -1165,6 +1223,7 @@ "SCzAbA": [["phase"], " — ", ["pct"], "%"], "SEYWYq": ["Undo all edits on this file?"], "SFN6dN": ["Heading 3"], + "SGYnqh": ["Permanently delete {0}?"], "STTOnz": [["keyName"], " type: complex value (nested; read-only)"], "SUOoXn": ["Failed to open project."], "SVLPt5": [["formatted"], " GitHub stars"], @@ -1214,6 +1273,8 @@ "UPjaXD": ["Couldn't duplicate \"", ["0"], "\": ", ["1"]], "UQAp48": ["Search templates"], "UQMMvx": ["Moved \"", ["name"], "\" to ", ["0"]], + "UQaKhB": ["Check that your SSH account can read its home directory."], + "UQqYNy": ["Port (optional)"], "URmyfc": ["Details"], "UTNVyS": ["Select the full source document."], "UTZ97u": ["Selection must be inside the project"], @@ -1250,9 +1311,11 @@ " has been moved, renamed, or deleted in your local copy. Please commit your changes or enable auto-sync." ], "Uuj_R4": ["New from template"], + "UvRSPL": ["Remote folders"], "Uw6ERJ": ["Couldn't copy: ", ["detail"]], "UyROxs": [["mins", "plural", { "one": ["#", " min ago"], "other": ["#", " min ago"] }]], "V48OUT": ["You deleted <0>", ["filePath"], " locally, but it was modified upstream."], + "V58SpF": ["SSH machine"], "V7QxyT": ["Connection dropped"], "VAHWL_": ["Connected as <0>@", ["signedInLogin"], ""], "VBE2dw": [ @@ -1340,6 +1403,7 @@ "XU0OXn": ["Enable auto-sync"], "XV5sC0": ["Open worktree"], "XW18jo": ["Page headings response did not match success schema"], + "XXsaBn": ["Enter a whole-number port from 1 to 65535."], "XYlJJT": ["Setting up"], "XcGbim": ["Show hidden files"], "Xet9MX": ["Failed to load backlinks"], @@ -1393,11 +1457,13 @@ "Yi0VMG": ["Upload ", ["keyName"]], "YjhKI5": ["Cloning..."], "YlDuro": ["Fit height"], + "Yopd0Q": ["Machine saved."], "YsEYYS": [ ["behind", "plural", { "one": ["#", " commit behind"], "other": ["#", " commits behind"] }] ], "YwPUUP": ["This pack is already set up here."], "YyXtnN": ["Underline"], + "Z0HzWe": ["devbox or user@example.com"], "Z1LF1M": ["Sharing a doc needs a GitHub repository. Create one for this project."], "Z2M5XM": ["Reason: ", ["reason"], " (", ["osDetail"], ")"], "Z3FXyt": ["Loading..."], @@ -1437,6 +1503,7 @@ "ZgGuA4": [["0", "plural", { "one": ["File"], "other": ["Files"] }]], "ZhhOwV": ["Quote"], "ZjtgI-": ["Command palette"], + "Zl4i3m": ["Enter a path to browse its folders."], "Zon_qG": ["Mirror source <0>", ["label"], ""], "ZptNUh": ["Show ", ["hidden"], " more"], "ZsGpr_": ["Paste link"], @@ -1459,6 +1526,7 @@ "." ], "_BthEY": ["Open in ", ["shareBranch"]], + "_MvHYe": ["the SSH machine"], "_NX6gG": ["Managing project AI tools is unavailable in this build."], "_PACVa": ["Release notes"], "_PBJRl": ["Add at least one letter or number."], @@ -1481,6 +1549,7 @@ "_eguJc": ["Uploading"], "_gkGSK": ["deleted template"], "_hPpdX": ["Skill renamed"], + "_jcZBu": ["Development server"], "_pY_Vq": ["The repository was moved, renamed, or deleted."], "_qD6mn": ["Pick the folder where you've cloned it."], "_rP3HI": [["minutes"], "m ago"], @@ -1528,6 +1597,9 @@ "bD8I7O": ["Complete"], "bGa3Fk": ["Uploading file"], "bH382I": ["Edit this HTML — the preview updates live."], + "bN4T1I": [ + "Remote files are deleted immediately. They are not moved to Trash, and this action cannot be undone." + ], "bSD4im": ["Search worktrees and branches"], "bS_boY": ["Replace current match"], "bUUVED": ["Asset"], @@ -1540,6 +1612,7 @@ "blFttG": ["Search projects"], "bmC18N": ["Clearing local collaboration data for \"", ["docName"], "\" before reconnecting."], "brCtGB": ["History unavailable"], + "buBL51": ["Remove SSH machine?"], "bultyE": [ "When enabled, the agent opens or refreshes the preview after each edit. Disable if you manage your own preview window (OK Desktop, a browser tab on another display, etc.)." ], @@ -1578,6 +1651,7 @@ "csDS2L": ["Available"], "ct2kv5": ["No headings in ", ["pageTarget"]], "cuA8QW": ["Organize specs"], + "czJUwk": ["Machine removed."], "d1JjXo": [ "Your local copy of branch <0>", ["branch"], @@ -1627,6 +1701,7 @@ "e0NrBM": ["Project"], "e1Rn_k": ["Open containing folder"], "e1hptq": ["Embed a video with native player controls."], + "e2zZ3E": ["Test connection"], "e4GHWP": ["Pull"], "e4PbgC": ["Duplicate the file-tree item copied from the Files sidebar."], "e8L17t": ["A non-empty folder already exists at this path. Pick a different folder."], @@ -1762,6 +1837,7 @@ "hY8pka": ["Select next occurrence"], "hYDc6B": ["Name cannot be empty"], "hYgDIe": ["Create"], + "hZRKnO": ["SSH host or config alias"], "hZoBHK": ["Empty starting content"], "haHgQj": ["Copied relative path"], "habOU_": ["Failed to update the auto-approve setting — ", ["detail"]], @@ -1796,6 +1872,7 @@ "iEQQyu": ["Agent"], "iEYxO4": ["Show thumbnails"], "iGCs04": ["No templates available"], + "iGIjUv": ["Keep machine"], "iH8pgl": ["Back"], "iHAJDj": ["Resolving default location"], "iKSP5j": ["Word wrap"], @@ -1812,6 +1889,7 @@ ], "iTylMl": ["Templates"], "iXTp5j": ["Scope: "], + "iZKtXv": ["Enter a remote path."], "idD8Ev": ["Saved"], "ig5vUe": ["Failed to load file"], "iilQXA": ["Folder duplicated"], @@ -1844,12 +1922,14 @@ "jSO1X5": ["Inherited templates can't be renamed here — rename it in the folder that owns it."], "jSY41h": ["\"", ["docName"], "\" took too long. Check your connection."], "jTLWxh": ["Embed an external page in an inline iframe (docs, demos, Figma, CodeSandbox)."], + "jTUEbc": ["Remote path"], "jX0IPx": ["A skill with this name already exists."], "jZ_E5M": ["Fetch failed — check the server logs for details."], "j_Z6g7": ["Shared default"], "jbSG8V": ["Show Files (", ["sidebarShortcutLabel"], ")"], "jdGUeI": ["Couldn't create template: ", ["error"]], "jerqF0": ["Loading branch state"], + "jgDQvg": ["Could not browse the SSH home directory. ", ["detail"]], "jhtG3I": [" Your selection has been restored."], "jjtmV_": ["First file name"], "jol95m": ["Failed to open Project Navigator."], @@ -1890,6 +1970,7 @@ "Don't see <0>Skills? Enable <1>Settings <2/> Capabilities <3/> Code execution and file creation first." ], "kYL1lH": ["Agent activity"], + "kZ_tbb": ["Add machine"], "k_pade": ["Config sharing is now shared. Commit the OK files to share with your team."], "k_yIIc": ["Will be created at: <0>", ["targetPreview"], ""], "kdzzOK": ["Off — search ranks by keyword only. No content leaves this computer."], @@ -1969,6 +2050,7 @@ "makN88": ["No patterns yet. Type a folder or file name below to start hiding files."], "mdKFEy": ["You cancelled loading \"", ["docName"], "\"."], "mdzKSV": [["nodeCount", "plural", { "one": ["#", " node"], "other": ["#", " nodes"] }]], + "mefYjP": ["OpenKnowledge checks the folder first and asks before creating project files."], "mf37rL": ["Retrying"], "mg0YGL": ["Source multi-cursor and multi-select"], "mk2poV": ["e.g. server.ts"], @@ -1992,6 +2074,7 @@ ], "mwMF9u": ["Open CodeMirror search in source editor mode."], "mxY24F": ["Duplicate the focused file-tree item when focus is in the Files sidebar."], + "n-PHuv": ["Up"], "n2lRue": ["Commit or stash changes to switch:"], "n5n_vi": ["Private repository"], "n6hXUm": [ @@ -2177,6 +2260,7 @@ "rBc32v": ["Couldn't reach the embeddings provider — press ↵ to retry"], "rFw02Q": ["Uploading ", ["keyName"]], "rJVoVX": ["Source find results"], + "rLiU78": ["Enter an SSH host or config alias."], "rNifQd": ["Remove property"], "rQAQus": ["Couldn't save template: ", ["error"]], "rQRrkB": ["yesterday"], @@ -2257,6 +2341,7 @@ "t6w1cq": ["Open a blank editor tab."], "tAPZEx": ["Below"], "tHKQ6j": ["Renaming"], + "tMCZ--": ["Enter a machine name."], "tP5DDk": ["Apply link"], "tUvUfp": ["Use a folder you already have."], "tW_yvQ": ["Expand ", ["docName"]], @@ -2317,6 +2402,7 @@ "uksqrP": ["Repository not found. It may have been renamed, deleted, or moved."], "un8kBM": ["Creating page: ", ["linkTitle"], "."], "ur2XSb": ["Visual editor"], + "usx04M": ["Remove machine"], "uuoHOU": ["Command failed."], "uynXup": [["tokens", "plural", { "one": ["token"], "other": ["tokens"] }]], "v-h2hx": ["Delete ", ["targetGitPath"]], @@ -2340,6 +2426,7 @@ "vLyv1R": ["Hide"], "vQIVHc": ["Folder properties"], "vWc9a0": ["Type to edit; <0>⌘ Enter saves, <1>Esc cancels."], + "vXckDu": ["It never deletes or changes projects, files, or other data on the remote machine."], "vZVeFG": ["Upload failed — please try again"], "vbK_ZG": ["Files sidebar"], "ve8vEM": ["Code expired — please try again"], @@ -2355,6 +2442,7 @@ "w4hMYl": [ "Could not read branch state for this project. Close this dialog and open the share link again." ], + "w61W3L": ["Remove ", ["0"]], "w6sBjd": ["Terminal settings not yet loaded — try again in a moment"], "w7SsvI": ["That folder isn't an OpenKnowledge project yet. Initialize it and open?"], "w7nm4K": ["Indented blockquote for citations."], @@ -2364,6 +2452,7 @@ "wJo9oS": ["Close find"], "wQp1V3": ["Mirror of <0>", ["src"], ""], "wT4iQr": ["Open visual-editor find with replace controls expanded."], + "wTTZ3E": ["Open over SSH"], "wTcjov": ["No workspace"], "wXO4Tg": [["hrs"], "h ago"], "w_Sphq": ["Attachments"], @@ -2381,6 +2470,7 @@ "Skills teach agents repeatable tasks. Author them here; install a skill to project it into your editors' skill folders." ], "wndeIl": ["Project-level pages with neither incoming nor outgoing graph edges."], + "wpFII3": ["Open project menu for ", ["0"], " on ", ["1"], " at ", ["2"]], "wtcxiD": ["Share this setup with your team?"], "wvbc1r": ["Couldn't open document"], "wzq9Rv": [ @@ -2450,6 +2540,7 @@ "yHikCO": ["Open CodeMirror's go-to-line prompt."], "yIPXut": ["Preparing search"], "yKu_3Y": ["Restore"], + "yKyDRc": ["Permanently delete selected items?"], "yLEyuq": ["Product updates in your inbox."], "yLq4w4": ["Separator"], "yMgL_E": ["Find field navigation"], @@ -2500,6 +2591,7 @@ "zLak8b": ["Activate new tab"], "zQBg2C": ["Move the current block up or down one position."], "zUhPuR": ["Open containing folder, ", ["hint"]], + "zUvb4N": ["Failed to remove the SSH machine."], "zXj1rA": ["Search by meaning"], "zZ_I5K": ["Open project menu"], "zaXlhf": [ diff --git a/packages/app/src/locales/en/messages.po b/packages/app/src/locales/en/messages.po index 77a7b7f06..4f3ccbcaf 100644 --- a/packages/app/src/locales/en/messages.po +++ b/packages/app/src/locales/en/messages.po @@ -549,6 +549,11 @@ msgstr "A pull quote stands out from the surrounding text." msgid "A real shell is off for this project. Turning it on runs commands with the full access of your macOS user account." msgstr "A real shell is off for this project. Turning it on runs commands with the full access of your macOS user account." +#. placeholder {0}: remote.machineName +#: src/components/settings/TerminalSection.tsx +msgid "A remote shell is off for this project. Turning it on runs commands with the full access of your SSH account on {0}." +msgstr "A remote shell is off for this project. Turning it on runs commands with the full access of your SSH account on {0}." + #: src/components/NewTemplateDialog.tsx msgid "A reusable starting point for new documents in this folder." msgstr "A reusable starting point for new documents in this folder." @@ -690,6 +695,10 @@ msgstr "Add item to {keyName}" msgid "Add link" msgstr "Add link" +#: src/components/RemoteProjectDialog.tsx +msgid "Add machine" +msgstr "Add machine" + #: src/components/settings/SearchSection.tsx msgid "Add meaning-based ranking to search so conceptually-related pages surface even when they share no keywords. This setting applies only to this computer." msgstr "Add meaning-based ranking to search so conceptually-related pages surface even when they share no keywords. This setting applies only to this computer." @@ -711,6 +720,10 @@ msgstr "Add property" msgid "Add property to {keyName}" msgstr "Add property to {keyName}" +#: src/components/RemoteProjectDialog.tsx +msgid "Add SSH machine" +msgstr "Add SSH machine" + #: src/editor/components/Tabs.tsx msgid "Add tab" msgstr "Add tab" @@ -987,6 +1000,7 @@ msgstr "Available in every project on this computer." msgid "Available in the OpenKnowledge desktop app. From a terminal, use<0> ok config-sharing status / <1>share / <2>unshare." msgstr "Available in the OpenKnowledge desktop app. From a terminal, use<0> ok config-sharing status / <1>share / <2>unshare." +#: src/components/RemoteProjectDialog.tsx #: src/components/SeedDialog.tsx msgid "Back" msgstr "Back" @@ -1184,6 +1198,7 @@ msgstr "Can't safely edit this tool's config" #: src/components/NewTemplateDialog.tsx #: src/components/NewWorktreeDialog.tsx #: src/components/PublishToGitHubDialog.tsx +#: src/components/RemoteProjectDialog.tsx #: src/components/SeedDialog.tsx #: src/components/settings/SearchSection.tsx #: src/components/ShareBranchSwitchDialog.tsx @@ -1214,6 +1229,14 @@ msgstr "Check out remote branch" msgid "Check out worktree" msgstr "Check out worktree" +#: src/components/RemoteProjectDialog.tsx +msgid "Check that the path exists and that your SSH account can read it." +msgstr "Check that the path exists and that your SSH account can read it." + +#: src/components/RemoteProjectDialog.tsx +msgid "Check that your SSH account can read its home directory." +msgstr "Check that your SSH account can read its home directory." + #: src/components/PropertyWidgets.tsx msgid "Checkbox" msgstr "Checkbox" @@ -1505,6 +1528,11 @@ msgstr "Commands" msgid "Commands run with the full access of your macOS user account on this machine. Turn this off to disable the shell." msgstr "Commands run with the full access of your macOS user account on this machine. Turn this off to disable the shell." +#. placeholder {0}: remote.machineName +#: src/components/settings/TerminalSection.tsx +msgid "Commands run with the full access of your SSH account on {0}. Turn this off to disable the remote shell." +msgstr "Commands run with the full access of your SSH account on {0}. Turn this off to disable the remote shell." + #: src/components/ShareBranchSwitchDialog.tsx msgid "Commit or stash changes to switch:" msgstr "Commit or stash changes to switch:" @@ -1618,6 +1646,10 @@ msgstr "Connect this project to GitHub to share." msgid "Connect to AI tools" msgstr "Connect to AI tools" +#: src/components/RemoteProjectDialog.tsx +msgid "Connect to an SSH machine and open a project directly from its filesystem." +msgstr "Connect to an SSH machine and open a project directly from its filesystem." + #: src/components/settings/SettingsDialogBody.tsx msgid "Connect to GitHub" msgstr "Connect to GitHub" @@ -1659,10 +1691,18 @@ msgstr "Connection dropped" msgid "Connection error — try again" msgstr "Connection error — try again" +#: src/components/RemoteProjectDialog.tsx +msgid "Connection failed." +msgstr "Connection failed." + #: src/presence/use-sync-toasts.ts msgid "Connection lost — keep this tab open, your edits will sync when reconnected" msgstr "Connection lost — keep this tab open, your edits will sync when reconnected" +#: src/components/RemoteProjectDialog.tsx +msgid "Connection successful." +msgstr "Connection successful." + #. Next step for a Codex project MCP row #: src/components/settings/ProjectAiToolsSection.tsx msgid "Connects automatically the next time you open this project in a trusted Codex session." @@ -1779,6 +1819,14 @@ msgstr "Copy the focused file-tree item so Paste can duplicate it." msgid "Copying..." msgstr "Copying..." +#: src/components/RemoteProjectDialog.tsx +msgid "Could not browse {requestedPath} on {machineName}. {detail}" +msgstr "Could not browse {requestedPath} on {machineName}. {detail}" + +#: src/components/RemoteProjectDialog.tsx +msgid "Could not browse the SSH home directory. {detail}" +msgstr "Could not browse the SSH home directory. {detail}" + #: src/components/FileTree.tsx msgid "Could not complete delete" msgstr "Could not complete delete" @@ -2034,6 +2082,10 @@ msgstr "Couldn't reach the embeddings provider — press ↵ to retry" msgid "Couldn't reconnect after server restart" msgstr "Couldn't reconnect after server restart" +#: src/lib/restart-collab-server.ts +msgid "Couldn't reconnect to the SSH project. Check the SSH connection and the remote OpenKnowledge prerequisites, then try again." +msgstr "Couldn't reconnect to the SSH project. Check the SSH connection and the remote OpenKnowledge prerequisites, then try again." + #: src/components/CreateProjectDialog.tsx msgid "Couldn't remove <0>{targetGitPath}: {removeGitError}" msgstr "Couldn't remove <0>{targetGitPath}: {removeGitError}" @@ -2329,6 +2381,10 @@ msgstr "Delete block" msgid "Delete code block" msgstr "Delete code block" +#: src/components/FileTree.tsx +msgid "Delete permanently" +msgstr "Delete permanently" + #: src/components/TrashFailureModal.tsx msgid "Delete Permanently" msgstr "Delete Permanently" @@ -2350,6 +2406,7 @@ msgid "deleted template" msgstr "deleted template" #: src/components/DeleteConfirmationDialog.tsx +#: src/components/FileTree.tsx #: src/components/TrashFailureModal.tsx msgid "Deleting" msgstr "Deleting" @@ -2412,6 +2469,14 @@ msgstr "Detected on this machine" msgid "Detecting" msgstr "Detecting" +#: src/components/RemoteProjectDialog.tsx +msgid "devbox or user@example.com" +msgstr "devbox or user@example.com" + +#: src/components/RemoteProjectDialog.tsx +msgid "Development server" +msgstr "Development server" + #: src/components/MermaidFileViewer.tsx msgid "Diagram" msgstr "Diagram" @@ -2787,10 +2852,22 @@ msgstr "Eng specs" msgid "Enter a folder name (e.g. brain)." msgstr "Enter a folder name (e.g. brain)." +#: src/components/RemoteProjectDialog.tsx +msgid "Enter a machine name." +msgstr "Enter a machine name." + +#: src/components/RemoteProjectDialog.tsx +msgid "Enter a path to browse its folders." +msgstr "Enter a path to browse its folders." + #: src/components/CreateProjectDialog.tsx msgid "Enter a project name" msgstr "Enter a project name" +#: src/components/RemoteProjectDialog.tsx +msgid "Enter a remote path." +msgstr "Enter a remote path." + #: src/components/CloneDialog.tsx msgid "Enter a repository URL or owner/repo" msgstr "Enter a repository URL or owner/repo" @@ -2807,6 +2884,14 @@ msgstr "Enter a valid branch name (no spaces, no leading dot, no \"..\")." msgid "Enter a valid URL (for example https://api.openai.com/v1)." msgstr "Enter a valid URL (for example https://api.openai.com/v1)." +#: src/components/RemoteProjectDialog.tsx +msgid "Enter a whole-number port from 1 to 65535." +msgstr "Enter a whole-number port from 1 to 65535." + +#: src/components/RemoteProjectDialog.tsx +msgid "Enter an SSH host or config alias." +msgstr "Enter an SSH host or config alias." + #: src/components/settings/AiToolsSection.tsx #: src/components/settings/ProjectAiToolsSection.tsx msgid "Entry" @@ -3007,6 +3092,10 @@ msgstr "Failed to load skill targets: {0}" msgid "Failed to load skills: {0}" msgstr "Failed to load skills: {0}" +#: src/components/RemoteProjectDialog.tsx +msgid "Failed to load SSH machines." +msgstr "Failed to load SSH machines." + #: src/components/CommandPalette.tsx msgid "Failed to load tags. Press Escape and re-open to retry." msgstr "Failed to load tags. Press Escape and re-open to retry." @@ -3038,6 +3127,10 @@ msgstr "Failed to open Project Navigator." msgid "Failed to open project." msgstr "Failed to open project." +#: src/components/RemoteProjectDialog.tsx +msgid "Failed to open the remote project." +msgstr "Failed to open the remote project." + #: src/components/CommandPalette.tsx #: src/components/RecentProjectsMenu.tsx msgid "Failed to open worktree." @@ -3059,6 +3152,10 @@ msgstr "Failed to parse forward-links error response (HTTP {status})" msgid "Failed to remove project." msgstr "Failed to remove project." +#: src/components/RemoteProjectDialog.tsx +msgid "Failed to remove the SSH machine." +msgstr "Failed to remove the SSH machine." + #: src/components/PropertyPanel.tsx msgid "Failed to rename" msgstr "Failed to rename" @@ -3081,6 +3178,10 @@ msgstr "Failed to render PDF" msgid "Failed to reorder" msgstr "Failed to reorder" +#: src/components/RemoteProjectDialog.tsx +msgid "Failed to save the SSH machine." +msgstr "Failed to save the SSH machine." + #: src/components/settings/SettingsDialogBody.tsx msgid "Failed to update attachment location — {detail}" msgstr "Failed to update attachment location — {detail}" @@ -3384,6 +3485,10 @@ msgstr "Global outside text fields" msgid "Global Skill" msgstr "Global Skill" +#: src/components/RemoteProjectDialog.tsx +msgid "Go" +msgstr "Go" + #: src/components/DocumentErrorBoundary.tsx #: src/components/LargeFileEditorState.tsx msgid "Go back" @@ -3840,6 +3945,10 @@ msgstr "Invalid input — pick a different folder." msgid "Invalid patch payload" msgstr "Invalid patch payload" +#: src/components/RemoteProjectDialog.tsx +msgid "It never deletes or changes projects, files, or other data on the remote machine." +msgstr "It never deletes or changes projects, files, or other data on the remote machine." + #: src/lib/keyboard-shortcuts.ts msgid "Italic" msgstr "Italic" @@ -3884,6 +3993,10 @@ msgstr "Keep disabled" msgid "Keep file deleted" msgstr "Keep file deleted" +#: src/components/RemoteProjectDialog.tsx +msgid "Keep machine" +msgstr "Keep machine" + #: src/components/DiffViewBoundary.tsx msgid "Keep my version" msgstr "Keep my version" @@ -4024,6 +4137,10 @@ msgstr "Loading files" msgid "Loading folder contents" msgstr "Loading folder contents" +#: src/components/RemoteProjectDialog.tsx +msgid "Loading folders..." +msgstr "Loading folders..." + #: src/components/DocPanel.tsx msgid "Loading graph" msgstr "Loading graph" @@ -4060,6 +4177,10 @@ msgstr "Loading repositories" msgid "Loading settings" msgstr "Loading settings" +#: src/components/RemoteProjectDialog.tsx +msgid "Loading SSH machines..." +msgstr "Loading SSH machines..." + #: src/components/PackCardGrid.tsx msgid "Loading starter packs" msgstr "Loading starter packs" @@ -4120,6 +4241,18 @@ msgstr "Looking for <0>{lookingForUrl}." msgid "Lost connection to \"{docName}\"." msgstr "Lost connection to \"{docName}\"." +#: src/components/RemoteProjectDialog.tsx +msgid "Machine name" +msgstr "Machine name" + +#: src/components/RemoteProjectDialog.tsx +msgid "Machine removed." +msgstr "Machine removed." + +#: src/components/RemoteProjectDialog.tsx +msgid "Machine saved." +msgstr "Machine saved." + #: src/components/settings/SettingsDialogBody.tsx msgid "Make this knowledge base available as a Claude Skill." msgstr "Make this knowledge base available as a Claude Skill." @@ -4550,6 +4683,10 @@ msgstr "No file or folder selected" msgid "No files yet." msgstr "No files yet." +#: src/components/RemoteProjectDialog.tsx +msgid "No folders in this location." +msgstr "No folders in this location." + #: src/components/SyncStatusBadge.tsx msgid "No git remote" msgstr "No git remote" @@ -5007,6 +5144,10 @@ msgstr "Open link" msgid "Open link in new tab" msgstr "Open link in new tab" +#: src/components/ProjectSwitcher.tsx +msgid "Open local folder" +msgstr "Open local folder" + #: src/components/ShareReceiveDialog.tsx msgid "Open Navigator" msgstr "Open Navigator" @@ -5015,18 +5156,37 @@ msgstr "Open Navigator" msgid "Open or close visual-editor find." msgstr "Open or close visual-editor find." +#: src/components/NavigatorApp.tsx +msgid "Open over SSH" +msgstr "Open over SSH" + #: src/components/settings/SettingsDialogBody.tsx msgid "Open preview when agent edits" msgstr "Open preview when agent edits" +#: src/components/RemoteProjectDialog.tsx +msgid "Open project" +msgstr "Open project" + #: src/components/ProjectSwitcher.tsx msgid "Open project menu" msgstr "Open project menu" +#. placeholder {0}: bridge.config.projectName +#. placeholder {1}: bridge.config.remote.machineName +#. placeholder {2}: bridge.config.remote.path +#: src/components/ProjectSwitcher.tsx +msgid "Open project menu for {0} on {1} at {2}" +msgstr "Open project menu for {0} on {1} at {2}" + #: src/components/CommandPalette.tsx msgid "Open recent project" msgstr "Open recent project" +#: src/components/RemoteProjectDialog.tsx +msgid "Open remote project" +msgstr "Open remote project" + #: src/components/ShareBranchSwitchDialog.tsx #: src/components/ShareReceiveDialog.tsx msgid "Open shared {targetNoun}" @@ -5112,10 +5272,18 @@ msgstr "Open, navigate, accept, or dismiss CodeMirror completion suggestions." msgid "Opening {openingLabel}…" msgstr "Opening {openingLabel}…" +#: src/components/RemoteProjectDialog.tsx +msgid "Opening remote project..." +msgstr "Opening remote project..." + #: src/components/ShareReceiveDialog.tsx msgid "Opening..." msgstr "Opening..." +#: src/components/RemoteProjectDialog.tsx +msgid "OpenKnowledge checks the folder first and asks before creating project files." +msgstr "OpenKnowledge checks the folder first and asks before creating project files." + #: src/components/ConsentDialogBody.tsx msgid "OpenKnowledge initializes at <0>{projectDir} — the parent of <1>{pickedRelative} because it contains a <2>.git folder (one .ok/ per git repo). <3>Content directory defaults to <4>. (the whole repo); type a sub-folder to narrow it." msgstr "OpenKnowledge initializes at <0>{projectDir} — the parent of <1>{pickedRelative} because it contains a <2>.git folder (one .ok/ per git repo). <3>Content directory defaults to <4>. (the whole repo); type a sub-folder to narrow it." @@ -5132,6 +5300,10 @@ msgstr "OpenKnowledge is using this GitHub account." msgid "OpenKnowledge stores its configuration and internal files inside a newly created <0>.ok directory in your project root folder." msgstr "OpenKnowledge stores its configuration and internal files inside a newly created <0>.ok directory in your project root folder." +#: src/components/RemoteProjectDialog.tsx +msgid "OpenKnowledge uses your system SSH config and SSH agent. Passwords and private keys are never stored." +msgstr "OpenKnowledge uses your system SSH config and SSH agent. Passwords and private keys are never stored." + #: src/components/CreateProjectDialog.tsx msgid "OpenKnowledge will be initialized at <0>{gitRoot} — the parent of your new folder, because it contains a <1>.git folder (one project per git repo)." msgstr "OpenKnowledge will be initialized at <0>{gitRoot} — the parent of your new folder, because it contains a <1>.git folder (one project per git repo)." @@ -5340,6 +5512,15 @@ msgstr "Pattern syntax error: {message}" msgid "Pattern warnings: {summary}" msgstr "Pattern warnings: {summary}" +#. placeholder {0}: trashTargetDisplayName(primaryDeleteTarget) +#: src/components/FileTree.tsx +msgid "Permanently delete '{0}'?" +msgstr "Permanently delete '{0}'?" + +#: src/components/FileTree.tsx +msgid "Permanently delete selected items?" +msgstr "Permanently delete selected items?" + #: src/components/CreateProjectDialog.tsx msgid "Permanently deletes <0>{targetGitPath} and all its git history. Working files stay in place. If the parent git repo is intentional (e.g. you cloned it), cancel and pick a location outside it." msgstr "Permanently deletes <0>{targetGitPath} and all its git history. Working files stay in place. If the parent git repo is intentional (e.g. you cloned it), cancel and pick a location outside it." @@ -5376,6 +5557,10 @@ msgstr "Plain" msgid "Please enter a valid email address." msgstr "Please enter a valid email address." +#: src/components/RemoteProjectDialog.tsx +msgid "Port (optional)" +msgstr "Port (optional)" + #: src/components/settings/SettingsDialogBody.tsx #: src/components/settings/SettingsDialogShell.tsx msgid "Preferences" @@ -5668,11 +5853,28 @@ msgstr "remote" msgid "Remote branch <0>{remoteCheckoutRef} will be checked out as a new local tracking branch <1>{trimmed}, in its own window under <2>.ok/worktrees/." msgstr "Remote branch <0>{remoteCheckoutRef} will be checked out as a new local tracking branch <1>{trimmed}, in its own window under <2>.ok/worktrees/." +#: src/components/FileTree.tsx +msgid "Remote files are deleted immediately. They are not moved to Trash, and this action cannot be undone." +msgstr "Remote files are deleted immediately. They are not moved to Trash, and this action cannot be undone." + +#: src/components/RemoteProjectDialog.tsx +msgid "Remote folders" +msgstr "Remote folders" + +#: src/components/RemoteProjectDialog.tsx +msgid "Remote path" +msgstr "Remote path" + #: src/editor/extensions/InternalLinkPropPanel.tsx #: src/editor/extensions/WikiLinkPropPanel.tsx msgid "Remove" msgstr "Remove" +#. placeholder {0}: selectedMachine.name +#: src/components/RemoteProjectDialog.tsx +msgid "Remove {0}" +msgstr "Remove {0}" + #: src/components/PropertyWidgets.tsx msgid "Remove {chip}" msgstr "Remove {chip}" @@ -5718,6 +5920,10 @@ msgstr "Remove item {0}" msgid "Remove link" msgstr "Remove link" +#: src/components/RemoteProjectDialog.tsx +msgid "Remove machine" +msgstr "Remove machine" + #: src/components/TemplateForm.tsx msgid "Remove property" msgstr "Remove property" @@ -5726,10 +5932,19 @@ msgstr "Remove property" msgid "Remove selection" msgstr "Remove selection" +#: src/components/RemoteProjectDialog.tsx +msgid "Remove SSH machine?" +msgstr "Remove SSH machine?" + #: src/components/CreateProjectDialog.tsx msgid "Remove the parent <0>.git folder" msgstr "Remove the parent <0>.git folder" +#. placeholder {0}: machinePendingRemoval.name +#: src/components/RemoteProjectDialog.tsx +msgid "Remove the saved connection for {0}?" +msgstr "Remove the saved connection for {0}?" + #. Warning shown when the user unchecks an already-installed skill #: src/components/McpConsentDialogBody.tsx msgid "Removes this skill from your editors." @@ -6009,6 +6224,12 @@ msgstr "Role" msgid "root" msgstr "root" +#. placeholder {0}: remote.machineName +#. placeholder {1}: remote.path +#: src/components/settings/TerminalSection.tsx +msgid "Run a real terminal docked inside OpenKnowledge on {0}, starting in {1}." +msgstr "Run a real terminal docked inside OpenKnowledge on {0}, starting in {1}." + #: src/components/settings/TerminalSection.tsx msgid "Run a real terminal docked inside OpenKnowledge, starting in this project's folder." msgstr "Run a real terminal docked inside OpenKnowledge, starting in this project's folder." @@ -6041,6 +6262,10 @@ msgstr "Save changes" msgid "Save failed: {error}" msgstr "Save failed: {error}" +#: src/components/RemoteProjectDialog.tsx +msgid "Save machine" +msgstr "Save machine" + #: src/components/DiffView.tsx msgid "Save resolution" msgstr "Save resolution" @@ -6649,6 +6874,18 @@ msgstr "Split diff" msgid "Split list item" msgstr "Split list item" +#: src/components/NavigatorApp.tsx +msgid "SSH" +msgstr "SSH" + +#: src/components/RemoteProjectDialog.tsx +msgid "SSH host or config alias" +msgstr "SSH host or config alias" + +#: src/components/RemoteProjectDialog.tsx +msgid "SSH machine" +msgstr "SSH machine" + #: src/components/SubscribeCard.tsx msgid "Star us on GitHub" msgstr "Star us on GitHub" @@ -7004,6 +7241,14 @@ msgstr "Terminal settings not loaded yet — try again in a moment." msgid "Terminal settings not yet loaded — try again in a moment" msgstr "Terminal settings not yet loaded — try again in a moment" +#: src/components/RemoteProjectDialog.tsx +msgid "Test connection" +msgstr "Test connection" + +#: src/components/RemoteProjectDialog.tsx +msgid "Testing also installs or updates matching OpenKnowledge support in the SSH user's home directory." +msgstr "Testing also installs or updates matching OpenKnowledge support in the SSH user's home directory." + #: src/components/PropertyWidgets.tsx msgid "Text" msgstr "Text" @@ -7077,6 +7322,10 @@ msgstr "The preview blocks resources that don't meet its security rules (for exa msgid "The properties block at the top of this doc has a formatting error. Switch to source mode to fix it." msgstr "The properties block at the top of this doc has a formatting error. Switch to source mode to fix it." +#: src/components/RemoteProjectDialog.tsx +msgid "The remote macOS or Linux machine needs Node.js 24 or newer and Git 2.31.0 or newer in a POSIX-compatible login shell. OpenKnowledge installs its remote support in your SSH home directory automatically." +msgstr "The remote macOS or Linux machine needs Node.js 24 or newer and Git 2.31.0 or newer in a POSIX-compatible login shell. OpenKnowledge installs its remote support in your SSH home directory automatically." + #: src/components/ShareReceiveDialog.tsx msgid "The repository is private and your GitHub account doesn't have access to it." msgstr "The repository is private and your GitHub account doesn't have access to it." @@ -7085,6 +7334,14 @@ msgstr "The repository is private and your GitHub account doesn't have access to msgid "The repository was moved, renamed, or deleted." msgstr "The repository was moved, renamed, or deleted." +#: src/components/RemoteProjectDialog.tsx +msgid "the SSH machine" +msgstr "the SSH machine" + +#: src/components/TerminalRefusalNotice.tsx +msgid "The SSH machine is unavailable, so the remote terminal couldn't start. Check the connection and try again." +msgstr "The SSH machine is unavailable, so the remote terminal couldn't start. Check the connection and try again." + #: src/components/TemplateProperties.tsx msgid "The template's filename (without `.md`)." msgstr "The template's filename (without `.md`)." @@ -7288,6 +7545,10 @@ msgstr "This project's running server doesn't support live editing. Restart Open msgid "This project's server (v{0}) is newer than this app (v{1})." msgstr "This project's server (v{0}) is newer than this app (v{1})." +#: src/components/RemoteProjectDialog.tsx +msgid "This removes the machine and its saved recent-project and session metadata from OpenKnowledge." +msgstr "This removes the machine and its saved recent-project and session metadata from OpenKnowledge." + #. placeholder {0}: skill.bundledVersion ?? '' #. placeholder {1}: skill.installedVersion #: src/components/SkillUpdateDialog.tsx @@ -7586,6 +7847,10 @@ msgstr "Unrecognized link" msgid "Unrecognized wiki link" msgstr "Unrecognized wiki link" +#: src/components/RemoteProjectDialog.tsx +msgid "Up" +msgstr "Up" + #: src/components/SkillUpdateDialog.tsx msgid "Update" msgstr "Update" @@ -7911,6 +8176,10 @@ msgstr "Will replace existing OpenKnowledge entry" msgid "Word wrap" msgstr "Word wrap" +#: src/components/NavigatorApp.tsx +msgid "Work with a project on another machine." +msgstr "Work with a project on another machine." + #: src/components/McpConsentDialogBody.tsx #: src/components/NewWorktreeDialog.tsx #: src/components/SkillEditorActions.tsx diff --git a/packages/app/src/locales/pseudo/messages.json b/packages/app/src/locales/pseudo/messages.json index 295bfc4f9..f6ef2cd5a 100644 --- a/packages/app/src/locales/pseudo/messages.json +++ b/packages/app/src/locales/pseudo/messages.json @@ -80,12 +80,15 @@ "0yL--g": ["Ŕēvĩēŵ ćōńƒĺĩćţś"], "10g8I_": ["Ţũŕń ōƒƒ Ōńĺŷ ḿàŕķďōŵń ƒĩĺēś"], "12F9WL": ["(ńō ĩď)"], + "15OSDO": ["ŚŚĤ"], "16D5Zj": ["Àŕē ŷōũ śũŕē ŷōũ ŵàńţ ţō ďēĺēţē ", ["itemName"], "? Ţĥĩś àćţĩōń ćàńńōţ ƀē ũńďōńē."], + "16NxA7": ["Ōƥēń ƥŕōĴēćţ"], "1AumWo": ["Ƥũśĥ ƒàĩĺēď — ćĥēćķ ţĥē śēŕvēŕ ĺōĝś ƒōŕ ďēţàĩĺś."], "1EcCom": [["keyName"], " vàĺũē"], "1FLarN": ["Ďēśćŕĩƀē ŵĥàţ ŷōũ ŵàńţ ţō ćŕēàţē ţō ćōńţĩńũē"], "1GhYxC": ["ōƒ ", ["totalPages"]], "1HBhDS": ["Ćĥēćķĩńĝ ōũţ ƒĩĺēś"], + "1Lcfdy": ["Ńō ƒōĺďēŕś ĩń ţĥĩś ĺōćàţĩōń."], "1MGRQl": ["Ţēŕḿĩńàĺ śēśśĩōńś"], "1QfxQT": ["Ďĩśḿĩśś"], "1RluQD": ["Śĥōŵ ŕēƥĺàćē"], @@ -117,6 +120,9 @@ "2QM24L": ["Àĺĺ ţàĝś"], "2RBzMG": ["Ńēţŵōŕķ ēŕŕōŕ — ćĥēćķ ŷōũŕ ćōńńēćţĩōń àńď ţŕŷ àĝàĩń"], "2XQ8Ww": ["Ōƥēń ƥŕēvĩēŵ ŵĥēń àĝēńţ ēďĩţś"], + "2Xcoe7": [ + "Ţĥĩś ŕēḿōvēś ţĥē ḿàćĥĩńē àńď ĩţś śàvēď ŕēćēńţ-ƥŕōĴēćţ àńď śēśśĩōń ḿēţàďàţà ƒŕōḿ ŌƥēńĶńōŵĺēďĝē." + ], "2YOwme": ["Ŕēśţàŕţēď — ńōŵ ŕũńńĩńĝ v", ["appRuntime"], "."], "2YZwPB": ["Ƒōŕŵàŕď-ĺĩńķś ŕēśƥōńśē ďĩď ńōţ ḿàţćĥ ēxƥēćţēď śĥàƥē."], "2bVHbB": ["Àvàĩĺàƀĺē ĩń ēvēŕŷ ƥŕōĴēćţ ōń ţĥĩś ćōḿƥũţēŕ."], @@ -137,6 +143,7 @@ "322lyb": ["Ĥĩĝĥĺĩĝĥţ ţĩƥś, ŵàŕńĩńĝś, àńď ńōţēś."], "34qyH5": ["Ćōũĺď ńōţ ŕēàćĥ śēŕvēŕ"], "36WXSC": ["Ćōũĺďń'ţ ďēĺēţē ţēḿƥĺàţē: ", ["error"]], + "36ZUr2": ["Ŕēḿōvē ţĥē śàvēď ćōńńēćţĩōń ƒōŕ ", ["0"], "?"], "36aWJD": ["ĵōĩń ũś ōń Ďĩśćōŕď"], "379t2h": ["Ũńĩƒĩēď ďĩƒƒ"], "38foVZ": ["Ĺōàďĩńĝ ďĩƒƒ"], @@ -164,6 +171,7 @@ "3a_Bd7": ["Ōƥēń śōũŕćē ďōć: ", ["mirrorSrc"]], "3aeN7X": ["Àĺĺ ƒĩĺēś"], "3cOo2C": ["Ćŕēàţē à ƒōĺďēŕ ƒŕōḿ ţĥē ćũŕŕēńţ ďōćũḿēńţ ōŕ ƒōĺďēŕ ćōńţēxţ."], + "3d4aXS": ["Ƒàĩĺēď ţō ĺōàď ŚŚĤ ḿàćĥĩńēś."], "3ePd3I": ["Ŵĥàţ'ś ńēŵ"], "3h5zMp": ["Śōḿēţĥĩńĝ ŵēńţ ŵŕōńĝ ĺōàďĩńĝ ţĥē śēţţĩńĝś ƥàńēĺ."], "3iyGWF": ["Ōƥēń ŕēćēńţ ƥŕōĴēćţ"], @@ -207,10 +215,12 @@ "4c_a6S": ["Ńōţĥĩńĝ śēţ ŷēţ."], "4g19V6": ["Ƒàĩĺēď ţō ũƥďàţē ƥŕōƥēŕţŷ"], "4hJhzz": ["Ţàƀĺē"], + "4iwBH3": ["Ŵōŕķ ŵĩţĥ à ƥŕōĴēćţ ōń àńōţĥēŕ ḿàćĥĩńē."], "4jDd0j": ["Àńŷōńē ćàń śēē"], "4mLBx9": ["Śũƀśćŕĩƥţĩōńś àŕēń'ţ àvàĩĺàƀĺē ŕĩĝĥţ ńōŵ."], "4niEzY": ["Śàńďƀōxēď ĤŢḾĹ ēḿƀēď — ŵŕĩţē ĤŢḾĹ, śēē ţĥē ŕēńďēŕēď ƥŕēvĩēŵ ĺĩvē."], "4nj6n8": ["/àƥĩ/ćōńƒĩĝ ŕēţũŕńēď ĤŢŢƤ ", ["code"]], + "4pSxwB": ["Ĝō"], "4rjwLG": ["Ćŕēàţē ŵĩţĥ ", ["0"], " ĆĹĨ"], "4sKwf6": ["Ƒĩĺē àĺŕēàďŷ ēxĩśţś"], "4uNLmk": ["Ƥũĺĺĩńĝ"], @@ -245,9 +255,18 @@ ["path"], ". Àĝēńţś ţĥàţ ŕēƒēŕēńćē ţĥĩś ţēḿƥĺàţē ƀŷ ńàḿē ŵĩĺĺ ƒàĩĺ ũńţĩĺ ĩţ'ś ŕēćŕēàţēď ōŕ ŕēƥĺàćēď ƀŷ ōńē ĩń à ƥàŕēńţ ƒōĺďēŕ." ], + "5NIAwW": ["Ćĥēćķ ţĥàţ ţĥē ƥàţĥ ēxĩśţś àńď ţĥàţ ŷōũŕ ŚŚĤ àććōũńţ ćàń ŕēàď ĩţ."], "5OTgC7": ["Ōƥēń àńď ƒōćũś ţĥē ƀōţţōḿ Àśķ ÀĨ ƥŕōḿƥţ ćōḿƥōśēŕ."], "5OqaJi": ["Ēńţēŕ à ƥŕōĴēćţ ńàḿē"], "5QGTaA": ["Ćōũĺď ńōţ ćōƥŷ ŕēĺàţĩvē ƥàţĥ"], + "5QRgp0": [ + "Ţĥē ŚŚĤ ḿàćĥĩńē ĩś ũńàvàĩĺàƀĺē, śō ţĥē ŕēḿōţē ţēŕḿĩńàĺ ćōũĺďń'ţ śţàŕţ. Ćĥēćķ ţĥē ćōńńēćţĩōń àńď ţŕŷ àĝàĩń." + ], + "5Srnlh": [ + "Ćōḿḿàńďś ŕũń ŵĩţĥ ţĥē ƒũĺĺ àććēśś ōƒ ŷōũŕ ŚŚĤ àććōũńţ ōń ", + ["0"], + ". Ţũŕń ţĥĩś ōƒƒ ţō ďĩśàƀĺē ţĥē ŕēḿōţē śĥēĺĺ." + ], "5SwVv1": ["Ńōţ ĩńśţàĺĺēď"], "5Tk8Fb": ["Ĺōàďĩńĝ ƒĩĺēś"], "5WDhAk": ["Ĺĩńķ ţō à ƥàĝē ōŕ ēxţēŕńàĺ ŨŔĹ."], @@ -342,6 +361,8 @@ "<0>ōķ ŵōń'ţ ŕũń ĩń ēxţēŕńàĺ ţēŕḿĩńàĺś ũńţĩĺ ŷōũ àďď ĩţ ĺàţēŕ ƒŕōḿ ţĥē Ƒĩĺē ḿēńũ. ŌƥēńĶńōŵĺēďĝē'ś ƀũĩĺţ-ĩń ţēŕḿĩńàĺ àńď ÀĨ ţōōĺś ķēēƥ ŵōŕķĩńĝ." ], "7PzzBU": ["Ũśēŕ"], + "7U78Tk": ["Ćōńńēćţĩōń śũććēśśƒũĺ."], + "7XZDcO": ["Śàvē ḿàćĥĩńē"], "7YRrJ9": [ "Àvàĩĺàƀĺē ĩń ţĥē ŌƥēńĶńōŵĺēďĝē ďēśķţōƥ àƥƥ. Ƒŕōḿ à ţēŕḿĩńàĺ, ũśē<0> ōķ ćōńƒĩĝ-śĥàŕĩńĝ śţàţũś / <1>śĥàŕē / <2>ũńśĥàŕē." ], @@ -358,6 +379,7 @@ "7uBlRT": ["Ćōƥŷ ţĥē ƒōćũśēď ƒĩĺē-ţŕēē ĩţēḿ śō Ƥàśţē ćàń ďũƥĺĩćàţē ĩţ."], "80c4-P": ["Ćōďē ćōƥĩēď ţō ćĺĩƥƀōàŕď"], "81H_23": ["Àďď ƥŕōƥēŕţĩēś"], + "82_cHI": ["Ćōũĺď ńōţ ƀŕōŵśē ", ["requestedPath"], " ōń ", ["machineName"], ". ", ["detail"]], "881Uwh": ["Ďēĺēţē śēĺēćţēď ţàƀĺē"], "89TtiD": [ "Ńēŵ ƒĩĺē ƒŕōḿ ţēḿƥĺàţē \"", @@ -391,6 +413,7 @@ "8fRyLo": ["Ĩńvàĺĩď ƥàţćĥ ƥàŷĺōàď"], "8fc_3j": ["Ƥŕōţēćţēď ƀŕàńćĥ — ćàńńōţ ƥũśĥ"], "8m8diM": ["Ńō ĺōćàţĩōń śēĺēćţēď. Ũśē ßŕōŵśē ţō ćĥōōśē à ƒōĺďēŕ."], + "8mIb7u": ["Ōƥēń ŕēḿōţē ƥŕōĴēćţ"], "8tjQCz": ["Ēxƥĺōŕē"], "8uAWix": [ "Ţĥĩś ţēḿƥĺàţē ĺĩvēś ĩń à ƥàŕēńţ ƒōĺďēŕ — ďēĺēţĩńĝ ĩţ àƒƒēćţś ēvēŕŷ ƒōĺďēŕ ƀēńēàţĥ ĩţ ţĥàţ ďōēśń'ţ ďēƒĩńē ĩţś ōŵń vēŕśĩōń." @@ -399,6 +422,11 @@ "92rbk2": ["Ḿĩŕŕōŕ ĺōàďĩńĝ <0>", ["mirrorRef"], ""], "96G6Re": ["Ńēŵ ƒōĺďēŕ"], "96w9hU": ["Śōḿēţĥĩńĝ ŵēńţ ŵŕōńĝ ōƥēńĩńĝ ţĥĩś ƒĩĺē (ĤŢŢƤ ", ["status"], ")."], + "97AViW": [ + "À ŕēḿōţē śĥēĺĺ ĩś ōƒƒ ƒōŕ ţĥĩś ƥŕōĴēćţ. Ţũŕńĩńĝ ĩţ ōń ŕũńś ćōḿḿàńďś ŵĩţĥ ţĥē ƒũĺĺ àććēśś ōƒ ŷōũŕ ŚŚĤ àććōũńţ ōń ", + ["0"], + "." + ], "99eR72": ["Vĩśũàĺ ēďĩţōŕ ţàƀĺē śēĺēćţĩōń"], "9Bd18D": ["Ƒàĩĺēď ţō ĺōàď ĥũƀ ƥàĝēś"], "9NJWgv": ["ßàćķĺĩńķś"], @@ -409,6 +437,7 @@ "9V0W-R": [ "Àďďś ţĥē ŌƥēńĶńōŵĺēďĝē śķĩĺĺ ţō ţĥē <0>Ćĺàũďē Ďēśķţōƥ Àƥƥ śō ĩţ'ś àvàĩĺàƀĺē ĩń Ćĥàţ àńď Ćōŵōŕķ śēśśĩōńś." ], + "9W2ANt": ["Ĺōàďĩńĝ ŚŚĤ ḿàćĥĩńēś..."], "9bCiQ8": ["Ńō ƥàĝēś àŕē ḿĩśśĩńĝ ĩńćōḿĩńĝ ĝŕàƥĥ ĺĩńķś."], "9clcD5": ["Ōƥēń ďōćũḿēńţś ţàĝĝēď #", ["chip"]], "9dw5bW": ["Ćŕēàţē ŵĩţĥ ", ["0"]], @@ -433,6 +462,7 @@ "9sBe85": ["Ƒōĺĺōŵ ũś ōń"], "9tJX8r": ["ßŷ ḿēàńĩńĝ"], "9zb2WA": ["Ćōńńēćţĩńĝ"], + "A10lA2": ["Ĺōàďĩńĝ ƒōĺďēŕś..."], "A1taO8": ["Śēàŕćĥ"], "A50vr3": ["Ũśē ĺōŵēŕćàśē ĺēţţēŕś, ďĩĝĩţś, àńď <0>- ōńĺŷ."], "A5CYnd": ["ßŕōķēń ĺĩńķ"], @@ -444,6 +474,7 @@ ], "AB4qw9": ["Ńēŵ ţēŕḿĩńàĺ"], "AE7BY3": ["Ţĥē ţēŕḿĩńàĺ śēśśĩōń ēńďēď."], + "AHveU-": ["Ďēĺēţē ƥēŕḿàńēńţĺŷ"], "AIn2Yv": [["displayName"], " ĆĹĨ, ", ["hint"]], "AItsE5": ["Ōƥēń ĩţ ţĥēŕē"], "AKxFtn": [["openedWorktreeCount", "plural", { "one": ["ŵōŕķţŕēē"], "other": ["ŵōŕķţŕēēś"] }]], @@ -520,6 +551,9 @@ "CCTop_": ["Ŕēćēńţ"], "CFnavk": ["Ţĥĩś ƥŕōĴēćţ ĩśń'ţ à ĝĩţ ŕēƥōśĩţōŕŷ, śō ŵōŕķţŕēēś àŕēń'ţ àvàĩĺàƀĺē."], "CKyk7Q": ["Ĝō ƀàćķ"], + "CLut6v": [ + "Ţēśţĩńĝ àĺśō ĩńśţàĺĺś ōŕ ũƥďàţēś ḿàţćĥĩńĝ ŌƥēńĶńōŵĺēďĝē śũƥƥōŕţ ĩń ţĥē ŚŚĤ ũśēŕ'ś ĥōḿē ďĩŕēćţōŕŷ." + ], "COr1Y7": [ "Ƥĩćķ <0>ōƥēńķńōŵĺēďĝē.śķĩĺĺ ƒŕōḿ ŷōũŕ <1>Ďōŵńĺōàďś ƒōĺďēŕ. Ĩţ ēńàƀĺēś àũţōḿàţĩćàĺĺŷ." ], @@ -577,6 +611,10 @@ ], "DhLHEb": ["Ƒàĩĺēď ţō ĺōàď àćţĩvĩţŷ"], "DiFKSL": ["Ćōũńţĩńĝ ḿàŕķďōŵń ƒĩĺēś"], + "DkVVq6": ["Ōƥēń ĺōćàĺ ƒōĺďēŕ"], + "Dmvx5e": [ + "Ćōũĺďń'ţ ŕēćōńńēćţ ţō ţĥē ŚŚĤ ƥŕōĴēćţ. Ćĥēćķ ţĥē ŚŚĤ ćōńńēćţĩōń àńď ţĥē ŕēḿōţē ŌƥēńĶńōŵĺēďĝē ƥŕēŕēǫũĩśĩţēś, ţĥēń ţŕŷ àĝàĩń." + ], "Dnn2XG": ["Àũţōḿàţĩć"], "DrYM2Z": ["Śŷńć ďĩśàƀĺēď"], "DskIkK": ["Śĩĝń-ĩń śţŕēàḿ ēńďēď ŵĩţĥōũţ ćōńƒĩŕḿàţĩōń — ƥĺēàśē ţŕŷ àĝàĩń"], @@ -600,6 +638,7 @@ ], "E7BhZn": ["ßĺàńķ ńōţē"], "E9bKSA": ["Ćŕēàţĩńĝ"], + "EAcqqW": ["Ćōńńēćţĩōń ƒàĩĺēď."], "EBVbtP": ["Ćōũĺď ńōţ ďũƥĺĩćàţē ĩţēḿ"], "EDl9kS": ["Śũƀśćŕĩƀē"], "EDm3wJ": ["Ĺōàďĩńĝ ďĩƒƒ ŕēńďēŕēŕ"], @@ -705,6 +744,7 @@ "GF51cw": [ "Ĝĩvē ţĥē ÀĨ ţōōĺś ŷōũ ũśē àććēśś ţō ŕēàď àńď ũƥďàţē ŷōũŕ ƥŕōĴēćţś. Ƥĩćķ ŵĥàţ ţō śēţ ũƥ ƀēĺōŵ, àńď ćĥàńĝē ĩţ àńŷţĩḿē." ], + "GFRtjt": ["Ōƥēńĩńĝ ŕēḿōţē ƥŕōĴēćţ..."], "GGsM4y": ["Śēĺēćţēď ĩń ĝŕàƥĥ"], "GHsJb8": ["Śĥōŵ .ōķ ƒōĺďēŕś"], "GJH-AT": [ @@ -771,6 +811,9 @@ "I1gRhX": ["Ƒàĩĺēď ţō ŕēńàḿē"], "I5685_": ["Ţŕŷ à ƥŕōḿƥţ"], "I5AfhE": ["Śŷńć śţàţũś ũńàvàĩĺàƀĺē — śēŕvēŕ ũńŕēàćĥàƀĺē."], + "I6NZOL": [ + "ŌƥēńĶńōŵĺēďĝē ũśēś ŷōũŕ śŷśţēḿ ŚŚĤ ćōńƒĩĝ àńď ŚŚĤ àĝēńţ. Ƥàśśŵōŕďś àńď ƥŕĩvàţē ķēŷś àŕē ńēvēŕ śţōŕēď." + ], "I7Exsw": ["Ƒōĺĺōŵ ũś ōń X"], "IA1wNb": ["Ţĥē Ćĺàũďē Ďēśķţōƥ Àƥƥ ďĩďń'ţ ōƥēń ţĥē ƒĩĺē. Ĩś ĩţ ĩńśţàĺĺēď? (", ["msg"], ")"], "IA6JW2": ["Ďēţēćţĩńĝ"], @@ -811,6 +854,7 @@ "JLzQi_": ["Ćĺōśē ", ["accessibleLabel"]], "JM_aq-": ["Ũśē àń ĥţţƥś:// ŨŔĹ — ĥţţƥ:// ĩś ōńĺŷ àĺĺōŵēď ƒōŕ ĺōćàĺĥōśţ."], "JOcPqH": ["ßŕĩńĝ à ŕēḿōţē ŕēƥōśĩţōŕŷ ōńţō ţĥĩś ḿàćĥĩńē."], + "JVR1YW": ["Ḿàćĥĩńē ńàḿē"], "JWsZlx": ["Ōƥēń à ƥŕōĴēćţ ţō ēďĩţ."], "JZ8cO6": ["Ĺĩĝĥţ, ďàŕķ, ōŕ ƒōĺĺōŵ ţĥē ŌŚ."], "Jejur6": ["Ćōũĺďń'ţ ĺōàď ďōćũḿēńţ"], @@ -844,6 +888,7 @@ "KPW5WC": ["ḿōvēď ţēḿƥĺàţē"], "KQGZuq": ["Ŕēũśēď ĩƒ ĩţ àĺŕēàďŷ ēxĩśţś, ćŕēàţēď ĩƒ ńōţ."], "KRJ6xh": ["Ńō ţàĝś ŷēţ. Ćōńţĩńũē ţŷƥĩńĝ ţō ćŕēàţē ōńē."], + "KUP3Yh": ["Ƒàĩĺēď ţō ōƥēń ţĥē ŕēḿōţē ƥŕōĴēćţ."], "KWL_Ih": ["ßĺōĝ ƥōśţ"], "KX1MZR": ["Ŷōũŕ ŕēƥōśĩţōŕĩēś"], "KYSocE": ["Ŕēśēàŕćĥ ţĥē ēxţĩńćţĩōń ōƒ ƒĺĩĝĥţĺēśś ƀĩŕďś"], @@ -970,10 +1015,12 @@ "N55YNr": [ "Ćĥōōśē ŵĥēţĥēŕ ţĥĩś ƥŕōĴēćţ'ś ŌƥēńĶńōŵĺēďĝē śēţũƥ, ĩńćĺũďĩńĝ ĩţś ÀĨ-ţōōĺ ćōńńēćţĩōńś, ĩś śàvēď ŵĩţĥ ţĥē ƥŕōĴēćţ śō ţēàḿḿàţēś ĝēţ ĩţ ţōō, ōŕ ķēƥţ ōńĺŷ ōń ŷōũŕ ćōḿƥũţēŕ." ], + "N5c59j": ["Ćōńńēćţ ţō àń ŚŚĤ ḿàćĥĩńē àńď ōƥēń à ƥŕōĴēćţ ďĩŕēćţĺŷ ƒŕōḿ ĩţś ƒĩĺēśŷśţēḿ."], "NEzPEh": ["Ćŕēàţē à ńēŵ ƀŕàńćĥ, ōŕ ćĥēćķ ōũţ àń ēxĩśţĩńĝ ōńē, ĩń ĩţś ōŵń ŵĩńďōŵ."], "NF_Wcg": ["Śĥōŵ śķĩĺĺś śēćţĩōń"], "NIWAux": ["Ńō ďōćś ŕēĝĩśţēŕēď ũńďēŕ #", ["tagDocsName"], "."], "NJyQi_": ["ßŕàńćĥ ", ["shareBranch"], " ńō ĺōńĝēŕ ēxĩśţś ōń ţĥē ŕēḿōţē."], + "NKJBO1": ["Ƒàĩĺēď ţō śàvē ţĥē ŚŚĤ ḿàćĥĩńē."], "NKkZXl": ["Ĩńĩţĩàĺĩźē àńď ōƥēń"], "NNJsn7": [ "Ōńē ḿōŕē śţēƥ: ēńàƀĺē ĩţ ĩń Ćũŕśōŕ → Śēţţĩńĝś → Ţōōĺś & ḾĆƤ (Ćũŕśōŕ ĺēàvēś ƥŕōĴēćţ śēŕvēŕś ōƒƒ ũńţĩĺ ŷōũ ţũŕń ţĥēḿ ōń)." @@ -984,6 +1031,7 @@ "NRSLBe": [["diffMin"], "ḿ àĝō"], "NSnIUP": ["Ţŵō-ƥàĝē (ēvēń)"], "NUr4kz": ["À ŕēũśàƀĺē śţàŕţĩńĝ ƥōĩńţ ƒōŕ ńēŵ ďōćũḿēńţś ĩń ţĥĩś ƒōĺďēŕ."], + "NVwtTv": ["Àďď ŚŚĤ ḿàćĥĩńē"], "NYUhy5": ["Śũƀƒōĺďēŕ ńàḿē"], "N_7kkF": [ "Ŕēàď ţĥŕōũĝĥ ţĥĩś ćōďēƀàśē àńď ďŕàƒţ à ţēćĥńĩćàĺ śƥēć ƒōŕ ţĥē ḿōśţ ćōḿƥĺēx ḿōďũĺē: àń ōvēŕvĩēŵ, ţĥē àŕćĥĩţēćţũŕē, ķēŷ ƒĩĺēś, àńď ōƥēń ǫũēśţĩōńś, àĺĺ ĺĩńķēď ƒŕōḿ à śƥēćś ĩńďēx ƥàĝē." @@ -1055,6 +1103,13 @@ "P6gXB-": ["Ŕēƥĺàćē ", ["keyName"]], "P6lAT3": ["Ƒōĺďēŕ ţàŕĝēţ: ", ["linkTitle"], ". Ćĺĩćķ ţō ōƥēń ţĥē ōvēŕvĩēŵ."], "P8yDjG": ["Ńàḿē ćàń’ţ ƀē ēḿƥţŷ, \".\", \"..\", ōŕ ćōńţàĩń / ōŕ \\"], + "PAbaOj": [ + "Ŕũń à ŕēàĺ ţēŕḿĩńàĺ ďōćķēď ĩńśĩďē ŌƥēńĶńōŵĺēďĝē ōń ", + ["0"], + ", śţàŕţĩńĝ ĩń ", + ["1"], + "." + ], "PB4upl": ["Ƒĩńď ƥŕēvĩōũś ḿàţćĥ"], "PBwmDJ": ["Śēàŕćĥĩńĝ ƀŷ ḿēàńĩńĝ"], "PBxg_E": ["Ńōţ ńōŵ"], @@ -1127,6 +1182,9 @@ "R9IDFz": ["Ŕēńàḿēś ţĥē ƒōĺďēŕ ōń ďĩśķ àńď ţĥē ĩď àĝēńţś ũśē ţō ĩńvōķē ĩţ."], "RDjuBN": ["Śēţũƥ"], "RDrXh4": ["Ćōńƒĩĝũŕē ũśēŕ, ƥŕōĴēćţ, àńď ĩńţēĝŕàţĩōń śēţţĩńĝś."], + "RFK-la": [ + "Ţĥē ŕēḿōţē ḿàćŌŚ ōŕ Ĺĩńũx ḿàćĥĩńē ńēēďś Ńōďē.Ĵś 24 ōŕ ńēŵēŕ àńď Ĝĩţ 2.31.0 ōŕ ńēŵēŕ ĩń à ƤŌŚĨX-ćōḿƥàţĩƀĺē ĺōĝĩń śĥēĺĺ. ŌƥēńĶńōŵĺēďĝē ĩńśţàĺĺś ĩţś ŕēḿōţē śũƥƥōŕţ ĩń ŷōũŕ ŚŚĤ ĥōḿē ďĩŕēćţōŕŷ àũţōḿàţĩćàĺĺŷ." + ], "RG4XHV": ["Ćōƥŷ ", ["0"], " ƥŕōḿƥţ"], "RGCCrg": ["Ćōƥŷ śĥàŕē ĺĩńķ"], "RGkNeJ": ["Ĩńĩţĩàĺĩźē à śţàŕţēŕ ƥàćķ"], @@ -1165,6 +1223,7 @@ "SCzAbA": [["phase"], " — ", ["pct"], "%"], "SEYWYq": ["Ũńďō àĺĺ ēďĩţś ōń ţĥĩś ƒĩĺē?"], "SFN6dN": ["Ĥēàďĩńĝ 3"], + "SGYnqh": ["Ƥēŕḿàńēńţĺŷ ďēĺēţē {0}?"], "STTOnz": [["keyName"], " ţŷƥē: ćōḿƥĺēx vàĺũē (ńēśţēď; ŕēàď-ōńĺŷ)"], "SUOoXn": ["Ƒàĩĺēď ţō ōƥēń ƥŕōĴēćţ."], "SVLPt5": [["formatted"], " ĜĩţĤũƀ śţàŕś"], @@ -1214,6 +1273,8 @@ "UPjaXD": ["Ćōũĺďń'ţ ďũƥĺĩćàţē \"", ["0"], "\": ", ["1"]], "UQAp48": ["Śēàŕćĥ ţēḿƥĺàţēś"], "UQMMvx": ["Ḿōvēď \"", ["name"], "\" ţō ", ["0"]], + "UQaKhB": ["Ćĥēćķ ţĥàţ ŷōũŕ ŚŚĤ àććōũńţ ćàń ŕēàď ĩţś ĥōḿē ďĩŕēćţōŕŷ."], + "UQqYNy": ["Ƥōŕţ (ōƥţĩōńàĺ)"], "URmyfc": ["Ďēţàĩĺś"], "UTNVyS": ["Śēĺēćţ ţĥē ƒũĺĺ śōũŕćē ďōćũḿēńţ."], "UTZ97u": ["Śēĺēćţĩōń ḿũśţ ƀē ĩńśĩďē ţĥē ƥŕōĴēćţ"], @@ -1250,9 +1311,11 @@ " ĥàś ƀēēń ḿōvēď, ŕēńàḿēď, ōŕ ďēĺēţēď ĩń ŷōũŕ ĺōćàĺ ćōƥŷ. Ƥĺēàśē ćōḿḿĩţ ŷōũŕ ćĥàńĝēś ōŕ ēńàƀĺē àũţō-śŷńć." ], "Uuj_R4": ["Ńēŵ ƒŕōḿ ţēḿƥĺàţē"], + "UvRSPL": ["Ŕēḿōţē ƒōĺďēŕś"], "Uw6ERJ": ["Ćōũĺďń'ţ ćōƥŷ: ", ["detail"]], "UyROxs": [["mins", "plural", { "one": ["#", " ḿĩń àĝō"], "other": ["#", " ḿĩń àĝō"] }]], "V48OUT": ["Ŷōũ ďēĺēţēď <0>", ["filePath"], " ĺōćàĺĺŷ, ƀũţ ĩţ ŵàś ḿōďĩƒĩēď ũƥśţŕēàḿ."], + "V58SpF": ["ŚŚĤ ḿàćĥĩńē"], "V7QxyT": ["Ćōńńēćţĩōń ďŕōƥƥēď"], "VAHWL_": ["Ćōńńēćţēď àś <0>@", ["signedInLogin"], ""], "VBE2dw": [ @@ -1340,6 +1403,7 @@ "XU0OXn": ["Ēńàƀĺē àũţō-śŷńć"], "XV5sC0": ["Ōƥēń ŵōŕķţŕēē"], "XW18jo": ["Ƥàĝē ĥēàďĩńĝś ŕēśƥōńśē ďĩď ńōţ ḿàţćĥ śũććēśś śćĥēḿà"], + "XXsaBn": ["Ēńţēŕ à ŵĥōĺē-ńũḿƀēŕ ƥōŕţ ƒŕōḿ 1 ţō 65535."], "XYlJJT": ["Śēţţĩńĝ ũƥ"], "XcGbim": ["Śĥōŵ ĥĩďďēń ƒĩĺēś"], "Xet9MX": ["Ƒàĩĺēď ţō ĺōàď ƀàćķĺĩńķś"], @@ -1393,11 +1457,13 @@ "Yi0VMG": ["Ũƥĺōàď ", ["keyName"]], "YjhKI5": ["Ćĺōńĩńĝ..."], "YlDuro": ["Ƒĩţ ĥēĩĝĥţ"], + "Yopd0Q": ["Ḿàćĥĩńē śàvēď."], "YsEYYS": [ ["behind", "plural", { "one": ["#", " ćōḿḿĩţ ƀēĥĩńď"], "other": ["#", " ćōḿḿĩţś ƀēĥĩńď"] }] ], "YwPUUP": ["Ţĥĩś ƥàćķ ĩś àĺŕēàďŷ śēţ ũƥ ĥēŕē."], "YyXtnN": ["Ũńďēŕĺĩńē"], + "Z0HzWe": ["ďēvƀōx ōŕ ũśēŕ@ēxàḿƥĺē.ćōḿ"], "Z1LF1M": ["Śĥàŕĩńĝ à ďōć ńēēďś à ĜĩţĤũƀ ŕēƥōśĩţōŕŷ. Ćŕēàţē ōńē ƒōŕ ţĥĩś ƥŕōĴēćţ."], "Z2M5XM": ["Ŕēàśōń: ", ["reason"], " (", ["osDetail"], ")"], "Z3FXyt": ["Ĺōàďĩńĝ..."], @@ -1437,6 +1503,7 @@ "ZgGuA4": [["0", "plural", { "one": ["Ƒĩĺē"], "other": ["Ƒĩĺēś"] }]], "ZhhOwV": ["Ǫũōţē"], "ZjtgI-": ["Ćōḿḿàńď ƥàĺēţţē"], + "Zl4i3m": ["Ēńţēŕ à ƥàţĥ ţō ƀŕōŵśē ĩţś ƒōĺďēŕś."], "Zon_qG": ["Ḿĩŕŕōŕ śōũŕćē <0>", ["label"], ""], "ZptNUh": ["Śĥōŵ ", ["hidden"], " ḿōŕē"], "ZsGpr_": ["Ƥàśţē ĺĩńķ"], @@ -1459,6 +1526,7 @@ "." ], "_BthEY": ["Ōƥēń ĩń ", ["shareBranch"]], + "_MvHYe": ["ţĥē ŚŚĤ ḿàćĥĩńē"], "_NX6gG": ["Ḿàńàĝĩńĝ ƥŕōĴēćţ ÀĨ ţōōĺś ĩś ũńàvàĩĺàƀĺē ĩń ţĥĩś ƀũĩĺď."], "_PACVa": ["Ŕēĺēàśē ńōţēś"], "_PBJRl": ["Àďď àţ ĺēàśţ ōńē ĺēţţēŕ ōŕ ńũḿƀēŕ."], @@ -1481,6 +1549,7 @@ "_eguJc": ["Ũƥĺōàďĩńĝ"], "_gkGSK": ["ďēĺēţēď ţēḿƥĺàţē"], "_hPpdX": ["Śķĩĺĺ ŕēńàḿēď"], + "_jcZBu": ["Ďēvēĺōƥḿēńţ śēŕvēŕ"], "_pY_Vq": ["Ţĥē ŕēƥōśĩţōŕŷ ŵàś ḿōvēď, ŕēńàḿēď, ōŕ ďēĺēţēď."], "_qD6mn": ["Ƥĩćķ ţĥē ƒōĺďēŕ ŵĥēŕē ŷōũ'vē ćĺōńēď ĩţ."], "_rP3HI": [["minutes"], "ḿ àĝō"], @@ -1528,6 +1597,9 @@ "bD8I7O": ["Ćōḿƥĺēţē"], "bGa3Fk": ["Ũƥĺōàďĩńĝ ƒĩĺē"], "bH382I": ["Ēďĩţ ţĥĩś ĤŢḾĹ — ţĥē ƥŕēvĩēŵ ũƥďàţēś ĺĩvē."], + "bN4T1I": [ + "Ŕēḿōţē ƒĩĺēś àŕē ďēĺēţēď ĩḿḿēďĩàţēĺŷ. Ţĥēŷ àŕē ńōţ ḿōvēď ţō Ţŕàśĥ, àńď ţĥĩś àćţĩōń ćàńńōţ ƀē ũńďōńē." + ], "bSD4im": ["Śēàŕćĥ ŵōŕķţŕēēś àńď ƀŕàńćĥēś"], "bS_boY": ["Ŕēƥĺàćē ćũŕŕēńţ ḿàţćĥ"], "bUUVED": ["Àśśēţ"], @@ -1540,6 +1612,7 @@ "blFttG": ["Śēàŕćĥ ƥŕōĴēćţś"], "bmC18N": ["Ćĺēàŕĩńĝ ĺōćàĺ ćōĺĺàƀōŕàţĩōń ďàţà ƒōŕ \"", ["docName"], "\" ƀēƒōŕē ŕēćōńńēćţĩńĝ."], "brCtGB": ["Ĥĩśţōŕŷ ũńàvàĩĺàƀĺē"], + "buBL51": ["Ŕēḿōvē ŚŚĤ ḿàćĥĩńē?"], "bultyE": [ "Ŵĥēń ēńàƀĺēď, ţĥē àĝēńţ ōƥēńś ōŕ ŕēƒŕēśĥēś ţĥē ƥŕēvĩēŵ àƒţēŕ ēàćĥ ēďĩţ. Ďĩśàƀĺē ĩƒ ŷōũ ḿàńàĝē ŷōũŕ ōŵń ƥŕēvĩēŵ ŵĩńďōŵ (ŌĶ Ďēśķţōƥ, à ƀŕōŵśēŕ ţàƀ ōń àńōţĥēŕ ďĩśƥĺàŷ, ēţć.)." ], @@ -1578,6 +1651,7 @@ "csDS2L": ["Àvàĩĺàƀĺē"], "ct2kv5": ["Ńō ĥēàďĩńĝś ĩń ", ["pageTarget"]], "cuA8QW": ["Ōŕĝàńĩźē śƥēćś"], + "czJUwk": ["Ḿàćĥĩńē ŕēḿōvēď."], "d1JjXo": [ "Ŷōũŕ ĺōćàĺ ćōƥŷ ōƒ ƀŕàńćĥ <0>", ["branch"], @@ -1627,6 +1701,7 @@ "e0NrBM": ["ƤŕōĴēćţ"], "e1Rn_k": ["Ōƥēń ćōńţàĩńĩńĝ ƒōĺďēŕ"], "e1hptq": ["Ēḿƀēď à vĩďēō ŵĩţĥ ńàţĩvē ƥĺàŷēŕ ćōńţŕōĺś."], + "e2zZ3E": ["Ţēśţ ćōńńēćţĩōń"], "e4GHWP": ["Ƥũĺĺ"], "e4PbgC": ["Ďũƥĺĩćàţē ţĥē ƒĩĺē-ţŕēē ĩţēḿ ćōƥĩēď ƒŕōḿ ţĥē Ƒĩĺēś śĩďēƀàŕ."], "e8L17t": ["À ńōń-ēḿƥţŷ ƒōĺďēŕ àĺŕēàďŷ ēxĩśţś àţ ţĥĩś ƥàţĥ. Ƥĩćķ à ďĩƒƒēŕēńţ ƒōĺďēŕ."], @@ -1762,6 +1837,7 @@ "hY8pka": ["Śēĺēćţ ńēxţ ōććũŕŕēńćē"], "hYDc6B": ["Ńàḿē ćàńńōţ ƀē ēḿƥţŷ"], "hYgDIe": ["Ćŕēàţē"], + "hZRKnO": ["ŚŚĤ ĥōśţ ōŕ ćōńƒĩĝ àĺĩàś"], "hZoBHK": ["Ēḿƥţŷ śţàŕţĩńĝ ćōńţēńţ"], "haHgQj": ["Ćōƥĩēď ŕēĺàţĩvē ƥàţĥ"], "habOU_": ["Ƒàĩĺēď ţō ũƥďàţē ţĥē àũţō-àƥƥŕōvē śēţţĩńĝ — ", ["detail"]], @@ -1796,6 +1872,7 @@ "iEQQyu": ["Àĝēńţ"], "iEYxO4": ["Śĥōŵ ţĥũḿƀńàĩĺś"], "iGCs04": ["Ńō ţēḿƥĺàţēś àvàĩĺàƀĺē"], + "iGIjUv": ["Ķēēƥ ḿàćĥĩńē"], "iH8pgl": ["ßàćķ"], "iHAJDj": ["Ŕēśōĺvĩńĝ ďēƒàũĺţ ĺōćàţĩōń"], "iKSP5j": ["Ŵōŕď ŵŕàƥ"], @@ -1812,6 +1889,7 @@ ], "iTylMl": ["Ţēḿƥĺàţēś"], "iXTp5j": ["Śćōƥē: "], + "iZKtXv": ["Ēńţēŕ à ŕēḿōţē ƥàţĥ."], "idD8Ev": ["Śàvēď"], "ig5vUe": ["Ƒàĩĺēď ţō ĺōàď ƒĩĺē"], "iilQXA": ["Ƒōĺďēŕ ďũƥĺĩćàţēď"], @@ -1844,12 +1922,14 @@ "jSO1X5": ["Ĩńĥēŕĩţēď ţēḿƥĺàţēś ćàń'ţ ƀē ŕēńàḿēď ĥēŕē — ŕēńàḿē ĩţ ĩń ţĥē ƒōĺďēŕ ţĥàţ ōŵńś ĩţ."], "jSY41h": ["\"", ["docName"], "\" ţōōķ ţōō ĺōńĝ. Ćĥēćķ ŷōũŕ ćōńńēćţĩōń."], "jTLWxh": ["Ēḿƀēď àń ēxţēŕńàĺ ƥàĝē ĩń àń ĩńĺĩńē ĩƒŕàḿē (ďōćś, ďēḿōś, Ƒĩĝḿà, ĆōďēŚàńďƀōx)."], + "jTUEbc": ["Ŕēḿōţē ƥàţĥ"], "jX0IPx": ["À śķĩĺĺ ŵĩţĥ ţĥĩś ńàḿē àĺŕēàďŷ ēxĩśţś."], "jZ_E5M": ["Ƒēţćĥ ƒàĩĺēď — ćĥēćķ ţĥē śēŕvēŕ ĺōĝś ƒōŕ ďēţàĩĺś."], "j_Z6g7": ["Śĥàŕēď ďēƒàũĺţ"], "jbSG8V": ["Śĥōŵ Ƒĩĺēś (", ["sidebarShortcutLabel"], ")"], "jdGUeI": ["Ćōũĺďń'ţ ćŕēàţē ţēḿƥĺàţē: ", ["error"]], "jerqF0": ["Ĺōàďĩńĝ ƀŕàńćĥ śţàţē"], + "jgDQvg": ["Ćōũĺď ńōţ ƀŕōŵśē ţĥē ŚŚĤ ĥōḿē ďĩŕēćţōŕŷ. ", ["detail"]], "jhtG3I": [" Ŷōũŕ śēĺēćţĩōń ĥàś ƀēēń ŕēśţōŕēď."], "jjtmV_": ["Ƒĩŕśţ ƒĩĺē ńàḿē"], "jol95m": ["Ƒàĩĺēď ţō ōƥēń ƤŕōĴēćţ Ńàvĩĝàţōŕ."], @@ -1890,6 +1970,7 @@ "Ďōń'ţ śēē <0>Śķĩĺĺś? Ēńàƀĺē <1>Śēţţĩńĝś <2/> Ćàƥàƀĩĺĩţĩēś <3/> Ćōďē ēxēćũţĩōń àńď ƒĩĺē ćŕēàţĩōń ƒĩŕśţ." ], "kYL1lH": ["Àĝēńţ àćţĩvĩţŷ"], + "kZ_tbb": ["Àďď ḿàćĥĩńē"], "k_pade": ["Ćōńƒĩĝ śĥàŕĩńĝ ĩś ńōŵ śĥàŕēď. Ćōḿḿĩţ ţĥē ŌĶ ƒĩĺēś ţō śĥàŕē ŵĩţĥ ŷōũŕ ţēàḿ."], "k_yIIc": ["Ŵĩĺĺ ƀē ćŕēàţēď àţ: <0>", ["targetPreview"], ""], "kdzzOK": ["Ōƒƒ — śēàŕćĥ ŕàńķś ƀŷ ķēŷŵōŕď ōńĺŷ. Ńō ćōńţēńţ ĺēàvēś ţĥĩś ćōḿƥũţēŕ."], @@ -1969,6 +2050,7 @@ "makN88": ["Ńō ƥàţţēŕńś ŷēţ. Ţŷƥē à ƒōĺďēŕ ōŕ ƒĩĺē ńàḿē ƀēĺōŵ ţō śţàŕţ ĥĩďĩńĝ ƒĩĺēś."], "mdKFEy": ["Ŷōũ ćàńćēĺĺēď ĺōàďĩńĝ \"", ["docName"], "\"."], "mdzKSV": [["nodeCount", "plural", { "one": ["#", " ńōďē"], "other": ["#", " ńōďēś"] }]], + "mefYjP": ["ŌƥēńĶńōŵĺēďĝē ćĥēćķś ţĥē ƒōĺďēŕ ƒĩŕśţ àńď àśķś ƀēƒōŕē ćŕēàţĩńĝ ƥŕōĴēćţ ƒĩĺēś."], "mf37rL": ["Ŕēţŕŷĩńĝ"], "mg0YGL": ["Śōũŕćē ḿũĺţĩ-ćũŕśōŕ àńď ḿũĺţĩ-śēĺēćţ"], "mk2poV": ["ē.ĝ. śēŕvēŕ.ţś"], @@ -1992,6 +2074,7 @@ ], "mwMF9u": ["Ōƥēń ĆōďēḾĩŕŕōŕ śēàŕćĥ ĩń śōũŕćē ēďĩţōŕ ḿōďē."], "mxY24F": ["Ďũƥĺĩćàţē ţĥē ƒōćũśēď ƒĩĺē-ţŕēē ĩţēḿ ŵĥēń ƒōćũś ĩś ĩń ţĥē Ƒĩĺēś śĩďēƀàŕ."], + "n-PHuv": ["Ũƥ"], "n2lRue": ["Ćōḿḿĩţ ōŕ śţàśĥ ćĥàńĝēś ţō śŵĩţćĥ:"], "n5n_vi": ["Ƥŕĩvàţē ŕēƥōśĩţōŕŷ"], "n6hXUm": [ @@ -2177,6 +2260,7 @@ "rBc32v": ["Ćōũĺďń'ţ ŕēàćĥ ţĥē ēḿƀēďďĩńĝś ƥŕōvĩďēŕ — ƥŕēśś ↵ ţō ŕēţŕŷ"], "rFw02Q": ["Ũƥĺōàďĩńĝ ", ["keyName"]], "rJVoVX": ["Śōũŕćē ƒĩńď ŕēśũĺţś"], + "rLiU78": ["Ēńţēŕ àń ŚŚĤ ĥōśţ ōŕ ćōńƒĩĝ àĺĩàś."], "rNifQd": ["Ŕēḿōvē ƥŕōƥēŕţŷ"], "rQAQus": ["Ćōũĺďń'ţ śàvē ţēḿƥĺàţē: ", ["error"]], "rQRrkB": ["ŷēśţēŕďàŷ"], @@ -2257,6 +2341,7 @@ "t6w1cq": ["Ōƥēń à ƀĺàńķ ēďĩţōŕ ţàƀ."], "tAPZEx": ["ßēĺōŵ"], "tHKQ6j": ["Ŕēńàḿĩńĝ"], + "tMCZ--": ["Ēńţēŕ à ḿàćĥĩńē ńàḿē."], "tP5DDk": ["Àƥƥĺŷ ĺĩńķ"], "tUvUfp": ["Ũśē à ƒōĺďēŕ ŷōũ àĺŕēàďŷ ĥàvē."], "tW_yvQ": ["Ēxƥàńď ", ["docName"]], @@ -2317,6 +2402,7 @@ "uksqrP": ["Ŕēƥōśĩţōŕŷ ńōţ ƒōũńď. Ĩţ ḿàŷ ĥàvē ƀēēń ŕēńàḿēď, ďēĺēţēď, ōŕ ḿōvēď."], "un8kBM": ["Ćŕēàţĩńĝ ƥàĝē: ", ["linkTitle"], "."], "ur2XSb": ["Vĩśũàĺ ēďĩţōŕ"], + "usx04M": ["Ŕēḿōvē ḿàćĥĩńē"], "uuoHOU": ["Ćōḿḿàńď ƒàĩĺēď."], "uynXup": [["tokens", "plural", { "one": ["ţōķēń"], "other": ["ţōķēńś"] }]], "v-h2hx": ["Ďēĺēţē ", ["targetGitPath"]], @@ -2340,6 +2426,7 @@ "vLyv1R": ["Ĥĩďē"], "vQIVHc": ["Ƒōĺďēŕ ƥŕōƥēŕţĩēś"], "vWc9a0": ["Ţŷƥē ţō ēďĩţ; <0>⌘ Ēńţēŕ śàvēś, <1>Ēść ćàńćēĺś."], + "vXckDu": ["Ĩţ ńēvēŕ ďēĺēţēś ōŕ ćĥàńĝēś ƥŕōĴēćţś, ƒĩĺēś, ōŕ ōţĥēŕ ďàţà ōń ţĥē ŕēḿōţē ḿàćĥĩńē."], "vZVeFG": ["Ũƥĺōàď ƒàĩĺēď — ƥĺēàśē ţŕŷ àĝàĩń"], "vbK_ZG": ["Ƒĩĺēś śĩďēƀàŕ"], "ve8vEM": ["Ćōďē ēxƥĩŕēď — ƥĺēàśē ţŕŷ àĝàĩń"], @@ -2355,6 +2442,7 @@ "w4hMYl": [ "Ćōũĺď ńōţ ŕēàď ƀŕàńćĥ śţàţē ƒōŕ ţĥĩś ƥŕōĴēćţ. Ćĺōśē ţĥĩś ďĩàĺōĝ àńď ōƥēń ţĥē śĥàŕē ĺĩńķ àĝàĩń." ], + "w61W3L": ["Ŕēḿōvē ", ["0"]], "w6sBjd": ["Ţēŕḿĩńàĺ śēţţĩńĝś ńōţ ŷēţ ĺōàďēď — ţŕŷ àĝàĩń ĩń à ḿōḿēńţ"], "w7SsvI": ["Ţĥàţ ƒōĺďēŕ ĩśń'ţ àń ŌƥēńĶńōŵĺēďĝē ƥŕōĴēćţ ŷēţ. Ĩńĩţĩàĺĩźē ĩţ àńď ōƥēń?"], "w7nm4K": ["Ĩńďēńţēď ƀĺōćķǫũōţē ƒōŕ ćĩţàţĩōńś."], @@ -2364,6 +2452,7 @@ "wJo9oS": ["Ćĺōśē ƒĩńď"], "wQp1V3": ["Ḿĩŕŕōŕ ōƒ <0>", ["src"], ""], "wT4iQr": ["Ōƥēń vĩśũàĺ-ēďĩţōŕ ƒĩńď ŵĩţĥ ŕēƥĺàćē ćōńţŕōĺś ēxƥàńďēď."], + "wTTZ3E": ["Ōƥēń ōvēŕ ŚŚĤ"], "wTcjov": ["Ńō ŵōŕķśƥàćē"], "wXO4Tg": [["hrs"], "ĥ àĝō"], "w_Sphq": ["Àţţàćĥḿēńţś"], @@ -2381,6 +2470,7 @@ "Śķĩĺĺś ţēàćĥ àĝēńţś ŕēƥēàţàƀĺē ţàśķś. Àũţĥōŕ ţĥēḿ ĥēŕē; ĩńśţàĺĺ à śķĩĺĺ ţō ƥŕōĴēćţ ĩţ ĩńţō ŷōũŕ ēďĩţōŕś' śķĩĺĺ ƒōĺďēŕś." ], "wndeIl": ["ƤŕōĴēćţ-ĺēvēĺ ƥàĝēś ŵĩţĥ ńēĩţĥēŕ ĩńćōḿĩńĝ ńōŕ ōũţĝōĩńĝ ĝŕàƥĥ ēďĝēś."], + "wpFII3": ["Ōƥēń ƥŕōĴēćţ ḿēńũ ƒōŕ ", ["0"], " ōń ", ["1"], " àţ ", ["2"]], "wtcxiD": ["Śĥàŕē ţĥĩś śēţũƥ ŵĩţĥ ŷōũŕ ţēàḿ?"], "wvbc1r": ["Ćōũĺďń'ţ ōƥēń ďōćũḿēńţ"], "wzq9Rv": [ @@ -2450,6 +2540,7 @@ "yHikCO": ["Ōƥēń ĆōďēḾĩŕŕōŕ'ś ĝō-ţō-ĺĩńē ƥŕōḿƥţ."], "yIPXut": ["Ƥŕēƥàŕĩńĝ śēàŕćĥ"], "yKu_3Y": ["Ŕēśţōŕē"], + "yKyDRc": ["Ƥēŕḿàńēńţĺŷ ďēĺēţē śēĺēćţēď ĩţēḿś?"], "yLEyuq": ["Ƥŕōďũćţ ũƥďàţēś ĩń ŷōũŕ ĩńƀōx."], "yLq4w4": ["Śēƥàŕàţōŕ"], "yMgL_E": ["Ƒĩńď ƒĩēĺď ńàvĩĝàţĩōń"], @@ -2500,6 +2591,7 @@ "zLak8b": ["Àćţĩvàţē ńēŵ ţàƀ"], "zQBg2C": ["Ḿōvē ţĥē ćũŕŕēńţ ƀĺōćķ ũƥ ōŕ ďōŵń ōńē ƥōśĩţĩōń."], "zUhPuR": ["Ōƥēń ćōńţàĩńĩńĝ ƒōĺďēŕ, ", ["hint"]], + "zUvb4N": ["Ƒàĩĺēď ţō ŕēḿōvē ţĥē ŚŚĤ ḿàćĥĩńē."], "zXj1rA": ["Śēàŕćĥ ƀŷ ḿēàńĩńĝ"], "zZ_I5K": ["Ōƥēń ƥŕōĴēćţ ḿēńũ"], "zaXlhf": [ diff --git a/packages/app/src/locales/pseudo/messages.po b/packages/app/src/locales/pseudo/messages.po index 727ddc036..d52d6e85b 100644 --- a/packages/app/src/locales/pseudo/messages.po +++ b/packages/app/src/locales/pseudo/messages.po @@ -544,6 +544,11 @@ msgstr "" msgid "A real shell is off for this project. Turning it on runs commands with the full access of your macOS user account." msgstr "" +#. placeholder {0}: remote.machineName +#: src/components/settings/TerminalSection.tsx +msgid "A remote shell is off for this project. Turning it on runs commands with the full access of your SSH account on {0}." +msgstr "" + #: src/components/NewTemplateDialog.tsx msgid "A reusable starting point for new documents in this folder." msgstr "" @@ -685,6 +690,10 @@ msgstr "" msgid "Add link" msgstr "" +#: src/components/RemoteProjectDialog.tsx +msgid "Add machine" +msgstr "" + #: src/components/settings/SearchSection.tsx msgid "Add meaning-based ranking to search so conceptually-related pages surface even when they share no keywords. This setting applies only to this computer." msgstr "" @@ -706,6 +715,10 @@ msgstr "" msgid "Add property to {keyName}" msgstr "" +#: src/components/RemoteProjectDialog.tsx +msgid "Add SSH machine" +msgstr "" + #: src/editor/components/Tabs.tsx msgid "Add tab" msgstr "" @@ -982,6 +995,7 @@ msgstr "" msgid "Available in the OpenKnowledge desktop app. From a terminal, use<0> ok config-sharing status / <1>share / <2>unshare." msgstr "" +#: src/components/RemoteProjectDialog.tsx #: src/components/SeedDialog.tsx msgid "Back" msgstr "" @@ -1179,6 +1193,7 @@ msgstr "" #: src/components/NewTemplateDialog.tsx #: src/components/NewWorktreeDialog.tsx #: src/components/PublishToGitHubDialog.tsx +#: src/components/RemoteProjectDialog.tsx #: src/components/SeedDialog.tsx #: src/components/settings/SearchSection.tsx #: src/components/ShareBranchSwitchDialog.tsx @@ -1209,6 +1224,14 @@ msgstr "" msgid "Check out worktree" msgstr "" +#: src/components/RemoteProjectDialog.tsx +msgid "Check that the path exists and that your SSH account can read it." +msgstr "" + +#: src/components/RemoteProjectDialog.tsx +msgid "Check that your SSH account can read its home directory." +msgstr "" + #: src/components/PropertyWidgets.tsx msgid "Checkbox" msgstr "" @@ -1500,6 +1523,11 @@ msgstr "" msgid "Commands run with the full access of your macOS user account on this machine. Turn this off to disable the shell." msgstr "" +#. placeholder {0}: remote.machineName +#: src/components/settings/TerminalSection.tsx +msgid "Commands run with the full access of your SSH account on {0}. Turn this off to disable the remote shell." +msgstr "" + #: src/components/ShareBranchSwitchDialog.tsx msgid "Commit or stash changes to switch:" msgstr "" @@ -1613,6 +1641,10 @@ msgstr "" msgid "Connect to AI tools" msgstr "" +#: src/components/RemoteProjectDialog.tsx +msgid "Connect to an SSH machine and open a project directly from its filesystem." +msgstr "" + #: src/components/settings/SettingsDialogBody.tsx msgid "Connect to GitHub" msgstr "" @@ -1654,10 +1686,18 @@ msgstr "" msgid "Connection error — try again" msgstr "" +#: src/components/RemoteProjectDialog.tsx +msgid "Connection failed." +msgstr "" + #: src/presence/use-sync-toasts.ts msgid "Connection lost — keep this tab open, your edits will sync when reconnected" msgstr "" +#: src/components/RemoteProjectDialog.tsx +msgid "Connection successful." +msgstr "" + #. Next step for a Codex project MCP row #: src/components/settings/ProjectAiToolsSection.tsx msgid "Connects automatically the next time you open this project in a trusted Codex session." @@ -1774,6 +1814,14 @@ msgstr "" msgid "Copying..." msgstr "" +#: src/components/RemoteProjectDialog.tsx +msgid "Could not browse {requestedPath} on {machineName}. {detail}" +msgstr "" + +#: src/components/RemoteProjectDialog.tsx +msgid "Could not browse the SSH home directory. {detail}" +msgstr "" + #: src/components/FileTree.tsx msgid "Could not complete delete" msgstr "" @@ -2029,6 +2077,10 @@ msgstr "" msgid "Couldn't reconnect after server restart" msgstr "" +#: src/lib/restart-collab-server.ts +msgid "Couldn't reconnect to the SSH project. Check the SSH connection and the remote OpenKnowledge prerequisites, then try again." +msgstr "" + #: src/components/CreateProjectDialog.tsx msgid "Couldn't remove <0>{targetGitPath}: {removeGitError}" msgstr "" @@ -2324,6 +2376,10 @@ msgstr "" msgid "Delete code block" msgstr "" +#: src/components/FileTree.tsx +msgid "Delete permanently" +msgstr "" + #: src/components/TrashFailureModal.tsx msgid "Delete Permanently" msgstr "" @@ -2345,6 +2401,7 @@ msgid "deleted template" msgstr "" #: src/components/DeleteConfirmationDialog.tsx +#: src/components/FileTree.tsx #: src/components/TrashFailureModal.tsx msgid "Deleting" msgstr "" @@ -2407,6 +2464,14 @@ msgstr "" msgid "Detecting" msgstr "" +#: src/components/RemoteProjectDialog.tsx +msgid "devbox or user@example.com" +msgstr "" + +#: src/components/RemoteProjectDialog.tsx +msgid "Development server" +msgstr "" + #: src/components/MermaidFileViewer.tsx msgid "Diagram" msgstr "" @@ -2782,10 +2847,22 @@ msgstr "" msgid "Enter a folder name (e.g. brain)." msgstr "" +#: src/components/RemoteProjectDialog.tsx +msgid "Enter a machine name." +msgstr "" + +#: src/components/RemoteProjectDialog.tsx +msgid "Enter a path to browse its folders." +msgstr "" + #: src/components/CreateProjectDialog.tsx msgid "Enter a project name" msgstr "" +#: src/components/RemoteProjectDialog.tsx +msgid "Enter a remote path." +msgstr "" + #: src/components/CloneDialog.tsx msgid "Enter a repository URL or owner/repo" msgstr "" @@ -2802,6 +2879,14 @@ msgstr "" msgid "Enter a valid URL (for example https://api.openai.com/v1)." msgstr "" +#: src/components/RemoteProjectDialog.tsx +msgid "Enter a whole-number port from 1 to 65535." +msgstr "" + +#: src/components/RemoteProjectDialog.tsx +msgid "Enter an SSH host or config alias." +msgstr "" + #: src/components/settings/AiToolsSection.tsx #: src/components/settings/ProjectAiToolsSection.tsx msgid "Entry" @@ -3002,6 +3087,10 @@ msgstr "" msgid "Failed to load skills: {0}" msgstr "" +#: src/components/RemoteProjectDialog.tsx +msgid "Failed to load SSH machines." +msgstr "" + #: src/components/CommandPalette.tsx msgid "Failed to load tags. Press Escape and re-open to retry." msgstr "" @@ -3033,6 +3122,10 @@ msgstr "" msgid "Failed to open project." msgstr "" +#: src/components/RemoteProjectDialog.tsx +msgid "Failed to open the remote project." +msgstr "" + #: src/components/CommandPalette.tsx #: src/components/RecentProjectsMenu.tsx msgid "Failed to open worktree." @@ -3054,6 +3147,10 @@ msgstr "" msgid "Failed to remove project." msgstr "" +#: src/components/RemoteProjectDialog.tsx +msgid "Failed to remove the SSH machine." +msgstr "" + #: src/components/PropertyPanel.tsx msgid "Failed to rename" msgstr "" @@ -3076,6 +3173,10 @@ msgstr "" msgid "Failed to reorder" msgstr "" +#: src/components/RemoteProjectDialog.tsx +msgid "Failed to save the SSH machine." +msgstr "" + #: src/components/settings/SettingsDialogBody.tsx msgid "Failed to update attachment location — {detail}" msgstr "" @@ -3379,6 +3480,10 @@ msgstr "" msgid "Global Skill" msgstr "" +#: src/components/RemoteProjectDialog.tsx +msgid "Go" +msgstr "" + #: src/components/DocumentErrorBoundary.tsx #: src/components/LargeFileEditorState.tsx msgid "Go back" @@ -3835,6 +3940,10 @@ msgstr "" msgid "Invalid patch payload" msgstr "" +#: src/components/RemoteProjectDialog.tsx +msgid "It never deletes or changes projects, files, or other data on the remote machine." +msgstr "" + #: src/lib/keyboard-shortcuts.ts msgid "Italic" msgstr "" @@ -3879,6 +3988,10 @@ msgstr "" msgid "Keep file deleted" msgstr "" +#: src/components/RemoteProjectDialog.tsx +msgid "Keep machine" +msgstr "" + #: src/components/DiffViewBoundary.tsx msgid "Keep my version" msgstr "" @@ -4019,6 +4132,10 @@ msgstr "" msgid "Loading folder contents" msgstr "" +#: src/components/RemoteProjectDialog.tsx +msgid "Loading folders..." +msgstr "" + #: src/components/DocPanel.tsx msgid "Loading graph" msgstr "" @@ -4055,6 +4172,10 @@ msgstr "" msgid "Loading settings" msgstr "" +#: src/components/RemoteProjectDialog.tsx +msgid "Loading SSH machines..." +msgstr "" + #: src/components/PackCardGrid.tsx msgid "Loading starter packs" msgstr "" @@ -4115,6 +4236,18 @@ msgstr "" msgid "Lost connection to \"{docName}\"." msgstr "" +#: src/components/RemoteProjectDialog.tsx +msgid "Machine name" +msgstr "" + +#: src/components/RemoteProjectDialog.tsx +msgid "Machine removed." +msgstr "" + +#: src/components/RemoteProjectDialog.tsx +msgid "Machine saved." +msgstr "" + #: src/components/settings/SettingsDialogBody.tsx msgid "Make this knowledge base available as a Claude Skill." msgstr "" @@ -4545,6 +4678,10 @@ msgstr "" msgid "No files yet." msgstr "" +#: src/components/RemoteProjectDialog.tsx +msgid "No folders in this location." +msgstr "" + #: src/components/SyncStatusBadge.tsx msgid "No git remote" msgstr "" @@ -5002,6 +5139,10 @@ msgstr "" msgid "Open link in new tab" msgstr "" +#: src/components/ProjectSwitcher.tsx +msgid "Open local folder" +msgstr "" + #: src/components/ShareReceiveDialog.tsx msgid "Open Navigator" msgstr "" @@ -5010,18 +5151,37 @@ msgstr "" msgid "Open or close visual-editor find." msgstr "" +#: src/components/NavigatorApp.tsx +msgid "Open over SSH" +msgstr "" + #: src/components/settings/SettingsDialogBody.tsx msgid "Open preview when agent edits" msgstr "" +#: src/components/RemoteProjectDialog.tsx +msgid "Open project" +msgstr "" + #: src/components/ProjectSwitcher.tsx msgid "Open project menu" msgstr "" +#. placeholder {0}: bridge.config.projectName +#. placeholder {1}: bridge.config.remote.machineName +#. placeholder {2}: bridge.config.remote.path +#: src/components/ProjectSwitcher.tsx +msgid "Open project menu for {0} on {1} at {2}" +msgstr "" + #: src/components/CommandPalette.tsx msgid "Open recent project" msgstr "" +#: src/components/RemoteProjectDialog.tsx +msgid "Open remote project" +msgstr "" + #: src/components/ShareBranchSwitchDialog.tsx #: src/components/ShareReceiveDialog.tsx msgid "Open shared {targetNoun}" @@ -5107,10 +5267,18 @@ msgstr "" msgid "Opening {openingLabel}…" msgstr "" +#: src/components/RemoteProjectDialog.tsx +msgid "Opening remote project..." +msgstr "" + #: src/components/ShareReceiveDialog.tsx msgid "Opening..." msgstr "" +#: src/components/RemoteProjectDialog.tsx +msgid "OpenKnowledge checks the folder first and asks before creating project files." +msgstr "" + #: src/components/ConsentDialogBody.tsx msgid "OpenKnowledge initializes at <0>{projectDir} — the parent of <1>{pickedRelative} because it contains a <2>.git folder (one .ok/ per git repo). <3>Content directory defaults to <4>. (the whole repo); type a sub-folder to narrow it." msgstr "" @@ -5127,6 +5295,10 @@ msgstr "" msgid "OpenKnowledge stores its configuration and internal files inside a newly created <0>.ok directory in your project root folder." msgstr "" +#: src/components/RemoteProjectDialog.tsx +msgid "OpenKnowledge uses your system SSH config and SSH agent. Passwords and private keys are never stored." +msgstr "" + #: src/components/CreateProjectDialog.tsx msgid "OpenKnowledge will be initialized at <0>{gitRoot} — the parent of your new folder, because it contains a <1>.git folder (one project per git repo)." msgstr "" @@ -5335,6 +5507,15 @@ msgstr "" msgid "Pattern warnings: {summary}" msgstr "" +#. placeholder {0}: trashTargetDisplayName(primaryDeleteTarget) +#: src/components/FileTree.tsx +msgid "Permanently delete '{0}'?" +msgstr "" + +#: src/components/FileTree.tsx +msgid "Permanently delete selected items?" +msgstr "" + #: src/components/CreateProjectDialog.tsx msgid "Permanently deletes <0>{targetGitPath} and all its git history. Working files stay in place. If the parent git repo is intentional (e.g. you cloned it), cancel and pick a location outside it." msgstr "" @@ -5371,6 +5552,10 @@ msgstr "" msgid "Please enter a valid email address." msgstr "" +#: src/components/RemoteProjectDialog.tsx +msgid "Port (optional)" +msgstr "" + #: src/components/settings/SettingsDialogBody.tsx #: src/components/settings/SettingsDialogShell.tsx msgid "Preferences" @@ -5663,11 +5848,28 @@ msgstr "" msgid "Remote branch <0>{remoteCheckoutRef} will be checked out as a new local tracking branch <1>{trimmed}, in its own window under <2>.ok/worktrees/." msgstr "" +#: src/components/FileTree.tsx +msgid "Remote files are deleted immediately. They are not moved to Trash, and this action cannot be undone." +msgstr "" + +#: src/components/RemoteProjectDialog.tsx +msgid "Remote folders" +msgstr "" + +#: src/components/RemoteProjectDialog.tsx +msgid "Remote path" +msgstr "" + #: src/editor/extensions/InternalLinkPropPanel.tsx #: src/editor/extensions/WikiLinkPropPanel.tsx msgid "Remove" msgstr "" +#. placeholder {0}: selectedMachine.name +#: src/components/RemoteProjectDialog.tsx +msgid "Remove {0}" +msgstr "" + #: src/components/PropertyWidgets.tsx msgid "Remove {chip}" msgstr "" @@ -5713,6 +5915,10 @@ msgstr "" msgid "Remove link" msgstr "" +#: src/components/RemoteProjectDialog.tsx +msgid "Remove machine" +msgstr "" + #: src/components/TemplateForm.tsx msgid "Remove property" msgstr "" @@ -5721,10 +5927,19 @@ msgstr "" msgid "Remove selection" msgstr "" +#: src/components/RemoteProjectDialog.tsx +msgid "Remove SSH machine?" +msgstr "" + #: src/components/CreateProjectDialog.tsx msgid "Remove the parent <0>.git folder" msgstr "" +#. placeholder {0}: machinePendingRemoval.name +#: src/components/RemoteProjectDialog.tsx +msgid "Remove the saved connection for {0}?" +msgstr "" + #. Warning shown when the user unchecks an already-installed skill #: src/components/McpConsentDialogBody.tsx msgid "Removes this skill from your editors." @@ -6004,6 +6219,12 @@ msgstr "" msgid "root" msgstr "" +#. placeholder {0}: remote.machineName +#. placeholder {1}: remote.path +#: src/components/settings/TerminalSection.tsx +msgid "Run a real terminal docked inside OpenKnowledge on {0}, starting in {1}." +msgstr "" + #: src/components/settings/TerminalSection.tsx msgid "Run a real terminal docked inside OpenKnowledge, starting in this project's folder." msgstr "" @@ -6036,6 +6257,10 @@ msgstr "" msgid "Save failed: {error}" msgstr "" +#: src/components/RemoteProjectDialog.tsx +msgid "Save machine" +msgstr "" + #: src/components/DiffView.tsx msgid "Save resolution" msgstr "" @@ -6644,6 +6869,18 @@ msgstr "" msgid "Split list item" msgstr "" +#: src/components/NavigatorApp.tsx +msgid "SSH" +msgstr "" + +#: src/components/RemoteProjectDialog.tsx +msgid "SSH host or config alias" +msgstr "" + +#: src/components/RemoteProjectDialog.tsx +msgid "SSH machine" +msgstr "" + #: src/components/SubscribeCard.tsx msgid "Star us on GitHub" msgstr "" @@ -6999,6 +7236,14 @@ msgstr "" msgid "Terminal settings not yet loaded — try again in a moment" msgstr "" +#: src/components/RemoteProjectDialog.tsx +msgid "Test connection" +msgstr "" + +#: src/components/RemoteProjectDialog.tsx +msgid "Testing also installs or updates matching OpenKnowledge support in the SSH user's home directory." +msgstr "" + #: src/components/PropertyWidgets.tsx msgid "Text" msgstr "" @@ -7072,6 +7317,10 @@ msgstr "" msgid "The properties block at the top of this doc has a formatting error. Switch to source mode to fix it." msgstr "" +#: src/components/RemoteProjectDialog.tsx +msgid "The remote macOS or Linux machine needs Node.js 24 or newer and Git 2.31.0 or newer in a POSIX-compatible login shell. OpenKnowledge installs its remote support in your SSH home directory automatically." +msgstr "" + #: src/components/ShareReceiveDialog.tsx msgid "The repository is private and your GitHub account doesn't have access to it." msgstr "" @@ -7080,6 +7329,14 @@ msgstr "" msgid "The repository was moved, renamed, or deleted." msgstr "" +#: src/components/RemoteProjectDialog.tsx +msgid "the SSH machine" +msgstr "" + +#: src/components/TerminalRefusalNotice.tsx +msgid "The SSH machine is unavailable, so the remote terminal couldn't start. Check the connection and try again." +msgstr "" + #: src/components/TemplateProperties.tsx msgid "The template's filename (without `.md`)." msgstr "" @@ -7283,6 +7540,10 @@ msgstr "" msgid "This project's server (v{0}) is newer than this app (v{1})." msgstr "" +#: src/components/RemoteProjectDialog.tsx +msgid "This removes the machine and its saved recent-project and session metadata from OpenKnowledge." +msgstr "" + #. placeholder {0}: skill.bundledVersion ?? '' #. placeholder {1}: skill.installedVersion #: src/components/SkillUpdateDialog.tsx @@ -7581,6 +7842,10 @@ msgstr "" msgid "Unrecognized wiki link" msgstr "" +#: src/components/RemoteProjectDialog.tsx +msgid "Up" +msgstr "" + #: src/components/SkillUpdateDialog.tsx msgid "Update" msgstr "" @@ -7906,6 +8171,10 @@ msgstr "" msgid "Word wrap" msgstr "" +#: src/components/NavigatorApp.tsx +msgid "Work with a project on another machine." +msgstr "" + #: src/components/McpConsentDialogBody.tsx #: src/components/NewWorktreeDialog.tsx #: src/components/SkillEditorActions.tsx diff --git a/packages/app/tests/stress/fixtures/handoff-mocks.ts b/packages/app/tests/stress/fixtures/handoff-mocks.ts index b2dbd75dc..fdd8cb333 100644 --- a/packages/app/tests/stress/fixtures/handoff-mocks.ts +++ b/packages/app/tests/stress/fixtures/handoff-mocks.ts @@ -357,6 +357,7 @@ export async function installHandoffMocks(page: Page, cfg: HandoffMockConfig): P apiOrigin: workerBaseURL, projectPath: workerContentDir, projectName: 'handoff-e2e-fixture', + remote: null, mode: 'editor' as const, e2eSmoke: false, singleFile: false, @@ -559,6 +560,18 @@ export async function installHandoffMocks(page: Page, cfg: HandoffMockConfig): P share: { validateLocalFolder: async () => ({ kind: 'not-git' as const }), }, + remote: { + listMachines: async () => [], + saveMachine: async (machine) => ({ ...machine, id: machine.id ?? 'mock-machine' }), + removeMachine: async () => {}, + testMachine: async () => ({ ok: true as const }), + listDirectories: async ({ path }) => ({ + path, + parentPath: null, + directories: [], + }), + openProject: async () => true, + }, // Sidebar context-menu surfaces — no-op stubs. Stress fixtures // don't exercise File-menu state-aware rebuilds or View-menu // tree-state push, but the `OkDesktopBridge` contract requires diff --git a/packages/cli/package.json b/packages/cli/package.json index d07cd23f9..ed08c4645 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -31,7 +31,7 @@ "access": "public" }, "scripts": { - "build:cli": "tsdown", + "build:cli": "tsdown && tsdown --config tsdown.remote.config.ts", "build:app": "cp -r ../app/dist dist/public", "build:skill-asset": "bun ../server/scripts/build-skill-bundles.ts && mkdir -p dist/assets/skills && cp -R ../server/dist/assets/skills/. dist/assets/skills/", "build:notices": "mkdir -p dist && cp ../../THIRD_PARTY_NOTICES.md dist/THIRD_PARTY_NOTICES.md", diff --git a/packages/cli/src/commands/remote.test.ts b/packages/cli/src/commands/remote.test.ts new file mode 100644 index 000000000..2ecf4643c --- /dev/null +++ b/packages/cli/src/commands/remote.test.ts @@ -0,0 +1,583 @@ +import { describe, expect, test } from 'bun:test'; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { + ConfigSchema, + PROTOCOL_VERSION, + RUNTIME_VERSION, + ServerLockCollisionError, + type ServerLockMetadata, +} from '@inkeep/open-knowledge-server'; +import { RemoteCompanionError } from '../remote-project-bootstrap.ts'; +import { + formatRemoteErrorLine, + formatRemoteInspectLine, + formatRemoteReadyLine, + MAX_REMOTE_READY_LINE_BYTES, + parseRemoteCompanionCommand, + parseRemoteCompanionNonce, + REMOTE_ERROR_PREFIX, + REMOTE_INSPECT_PREFIX, + REMOTE_READY_PREFIX, + type RemoteReadyPayload, + type RemoteServeDeps, + readRemoteTerminalConsent, + runRemoteServe, + shouldWatchRemoteStdin, + waitForRemoteTerminalConsent, +} from './remote.ts'; + +const config = ConfigSchema.parse({}); +const NONCE = 'A'.repeat(43); + +function liveLock(overrides: Partial = {}): ServerLockMetadata { + return { + pid: process.pid, + hostname: 'remote-host', + port: 43123, + startedAt: '2026-07-13T00:00:00.000Z', + worktreeRoot: '/project', + kind: 'interactive', + capabilities: ['http', 'ws'], + protocolVersion: PROTOCOL_VERSION, + runtimeVersion: RUNTIME_VERSION, + ...overrides, + }; +} + +function parseReadyLine(line: string): RemoteReadyPayload { + expect(line.startsWith(REMOTE_READY_PREFIX)).toBe(true); + expect(line.endsWith('\n')).toBe(true); + expect(line.slice(0, -1)).not.toContain('\n'); + return JSON.parse(line.slice(REMOTE_READY_PREFIX.length)) as RemoteReadyPayload; +} + +describe('parseRemoteCompanionCommand', () => { + const expectedPath = '/srv/wiki'; + const encodedExpectedPath = Buffer.from(expectedPath).toString('base64url'); + + test.each([ + [['inspect'], { name: 'inspect' }], + [['serve'], { name: 'serve', initialize: false, waitForOwnerExit: false }], + [ + ['serve', '--wait-for-owner-exit'], + { name: 'serve', initialize: false, waitForOwnerExit: true }, + ], + [ + ['serve', '--initialize', '--expected-path', encodedExpectedPath], + { name: 'serve', initialize: true, expectedPath, waitForOwnerExit: false }, + ], + [['terminal-consent'], { name: 'terminal-consent' }], + ] as const)('parses the internal command %j', (args, expected) => { + expect(parseRemoteCompanionCommand(args)).toEqual(expected); + }); + + test.each([ + { args: [] }, + { args: ['serve', '--initialize'] }, + { args: ['serve', '--port', '42'] }, + { args: ['serve', '--initialize', '--initialize'] }, + { args: ['serve', '--initialize', '--expected-path', '%%%'] }, + { args: ['other'] }, + ])('rejects unsupported arguments %j', ({ args }) => { + expect(() => parseRemoteCompanionCommand(args)).toThrow(RemoteCompanionError); + }); +}); + +describe('parseRemoteCompanionNonce', () => { + test('accepts one exact 256-bit base64url nonce argument', () => { + expect(parseRemoteCompanionNonce(['--nonce', NONCE, 'inspect'])).toBe(NONCE); + }); + + test.each([ + { args: [] }, + { args: ['inspect'] }, + { args: ['--nonce'] }, + { args: ['--nonce', 'short', 'inspect'] }, + { args: ['--nonce', `${'A'.repeat(42)}+`, 'inspect'] }, + ])('rejects an invalid invocation prefix %j', ({ args }) => { + expect(() => parseRemoteCompanionNonce(args)).toThrow(RemoteCompanionError); + }); +}); + +describe('remote companion frames', () => { + test('emits a compact, single-line, prefixed JSON record', () => { + const line = formatRemoteReadyLine({ + v: 1, + nonce: NONCE, + port: 1234, + projectPath: '/srv/a project', + platform: 'linux', + pathSeparator: '/', + protocolVersion: 1, + runtimeVersion: '0.30.0', + capabilities: ['http', 'ws'], + owned: true, + }); + + expect(parseReadyLine(line)).toEqual({ + v: 1, + nonce: NONCE, + port: 1234, + projectPath: '/srv/a project', + platform: 'linux', + pathSeparator: '/', + protocolVersion: 1, + runtimeVersion: '0.30.0', + capabilities: ['http', 'ws'], + owned: true, + }); + expect(Buffer.byteLength(line)).toBeLessThanOrEqual(MAX_REMOTE_READY_LINE_BYTES); + }); + + test('rejects an oversized frame instead of writing an unbounded line', () => { + expect(() => + formatRemoteReadyLine({ + v: 1, + nonce: NONCE, + port: 1234, + projectPath: `/${'x'.repeat(MAX_REMOTE_READY_LINE_BYTES)}`, + platform: 'linux', + pathSeparator: '/', + protocolVersion: 1, + runtimeVersion: RUNTIME_VERSION, + capabilities: ['http', 'ws'], + owned: true, + }), + ).toThrow('Remote companion frame is too large'); + }); + + test('emits bounded inspect and code-only error frames', () => { + const inspection = formatRemoteInspectLine(NONCE, { + v: 1, + selectedPath: '/srv/wiki/notes', + projectPath: '/srv/wiki', + initialized: true, + }); + const error = formatRemoteErrorLine(NONCE, 'project-uninitialized'); + + expect(inspection).toBe( + `${REMOTE_INSPECT_PREFIX}{"v":1,"selectedPath":"/srv/wiki/notes","projectPath":"/srv/wiki","initialized":true,"nonce":"${NONCE}"}\n`, + ); + expect(error).toBe( + `${REMOTE_ERROR_PREFIX}{"v":1,"nonce":"${NONCE}","code":"project-uninitialized"}\n`, + ); + expect(Buffer.byteLength(inspection)).toBeLessThanOrEqual(MAX_REMOTE_READY_LINE_BYTES); + expect(Buffer.byteLength(error)).toBeLessThanOrEqual(MAX_REMOTE_READY_LINE_BYTES); + }); +}); + +describe('remote terminal consent', () => { + test('refuses only an explicit project-local terminal opt-out', () => { + const project = mkdtempSync(join(tmpdir(), 'ok-remote-consent-')); + try { + const localDir = join(project, '.ok', 'local'); + mkdirSync(localDir, { recursive: true }); + writeFileSync(join(localDir, 'config.yml'), 'terminal:\n enabled: false\n'); + expect(readRemoteTerminalConsent(project)).toBe(false); + + writeFileSync(join(localDir, 'config.yml'), 'terminal:\n enabled: true\n'); + expect(readRemoteTerminalConsent(project)).toBe(true); + } finally { + rmSync(project, { recursive: true, force: true }); + } + }); + + test('fails loudly when the project-local terminal override is malformed', () => { + const project = mkdtempSync(join(tmpdir(), 'ok-remote-consent-')); + try { + const localDir = join(project, '.ok', 'local'); + mkdirSync(localDir, { recursive: true }); + writeFileSync(join(localDir, 'config.yml'), 'terminal: ['); + + expect(() => readRemoteTerminalConsent(project)).toThrow(RemoteCompanionError); + try { + readRemoteTerminalConsent(project); + } catch (error) { + expect(error).toMatchObject({ code: 'config-invalid' }); + } + } finally { + rmSync(project, { recursive: true, force: true }); + } + }); + + test('fails loudly when the project-local terminal override cannot be read as a file', () => { + const project = mkdtempSync(join(tmpdir(), 'ok-remote-consent-')); + try { + mkdirSync(join(project, '.ok', 'local', 'config.yml'), { recursive: true }); + + expect(() => readRemoteTerminalConsent(project)).toThrow(RemoteCompanionError); + try { + readRemoteTerminalConsent(project); + } catch (error) { + expect(error).toMatchObject({ code: 'config-invalid' }); + } + } finally { + rmSync(project, { recursive: true, force: true }); + } + }); + + test('uses the default allow policy only when no local override exists', () => { + const project = mkdtempSync(join(tmpdir(), 'ok-remote-consent-')); + try { + expect(readRemoteTerminalConsent(project)).toBe(true); + } finally { + rmSync(project, { recursive: true, force: true }); + } + }); + + test('waits for a debounced opt-out lift', async () => { + const reads = [false, false, true]; + const sleeps: number[] = []; + const allowed = await waitForRemoteTerminalConsent('/project', { + timeoutMs: 10_000, + intervalMs: 50, + read: () => reads.shift() ?? true, + sleep: async (ms) => { + sleeps.push(ms); + }, + }); + + expect(allowed).toBe(true); + expect(sleeps).toEqual([50, 50]); + }); + + test('returns false immediately when an opt-out remains and grace is disabled', async () => { + expect( + await waitForRemoteTerminalConsent('/project', { + timeoutMs: 0, + read: () => false, + }), + ).toBe(false); + }); +}); + +describe('shouldWatchRemoteStdin', () => { + test.each([ + [{ SSH_CONNECTION: 'client 1 server 2' }, false, true], + [{ SSH_CLIENT: 'client 1 2' }, undefined, true], + [{ SSH_TTY: '/dev/pts/1' }, false, true], + [{}, false, false], + [{ SSH_CONNECTION: 'client 1 server 2' }, true, false], + ] as const)('detects SSH pipe lifetime for env=%j tty=%j', (env, stdinIsTTY, expected) => { + expect(shouldWatchRemoteStdin(env, stdinIsTTY)).toBe(expected); + }); +}); + +describe('runRemoteServe', () => { + test('refuses every pre-existing live owner without booting or emitting readiness', async () => { + const stdout: string[] = []; + let bootCalls = 0; + + await expect( + runRemoteServe({ + config, + cwd: '/logical/project', + resolvedContentDir: '/canonical/project', + nonce: NONCE, + deps: { + canonicalize: (path) => (path === '/logical/project' ? '/canonical/project' : path), + lockDir: (projectPath) => `${projectPath}/.ok/local`, + readLock: () => liveLock({ port: 54321, worktreeRoot: '/canonical/project' }), + boot: async () => { + bootCalls += 1; + throw new Error('must not boot'); + }, + writeStdout: (line) => stdout.push(line), + }, + }), + ).rejects.toMatchObject({ code: 'startup-failed' }); + expect(bootCalls).toBe(0); + expect(stdout).toEqual([]); + }); + + test('waits only when explicitly replacing an owner, then starts a wholly owned server', async () => { + const stdout: string[] = []; + let waitCalls = 0; + + const result = await runRemoteServe({ + config, + cwd: '/project', + resolvedContentDir: '/project', + nonce: NONCE, + waitForOwnerExit: true, + deps: { + canonicalize: (path) => path, + readLock: () => liveLock(), + waitForLockRelease: async () => { + waitCalls += 1; + return true; + }, + boot: async () => ({ + port: 54321, + ready: Promise.resolve(), + destroy: async () => {}, + }), + writeStdout: (line) => stdout.push(line), + watchStdinForDisconnect: false, + }, + }); + + expect(waitCalls).toBe(1); + expect(result).toMatchObject({ owned: true, port: 54321 }); + expect(parseReadyLine(stdout[0] ?? '')).toMatchObject({ nonce: NONCE, owned: true }); + await result.shutdown('SIGTERM'); + }); + + test('fails explicitly when the previous owner does not exit before the deadline', async () => { + await expect( + runRemoteServe({ + config, + cwd: '/project', + resolvedContentDir: '/project', + nonce: NONCE, + waitForOwnerExit: true, + deps: { + canonicalize: (path) => path, + readLock: () => liveLock(), + waitForLockRelease: async () => false, + boot: async () => { + throw new Error('must not boot'); + }, + }, + }), + ).rejects.toMatchObject({ code: 'startup-failed' }); + }); + + test('rejects an acquisition collision instead of trusting the winner', async () => { + const stdout: string[] = []; + const racedLock = liveLock(); + const collision = new ServerLockCollisionError(racedLock, '/canonical/project/server.lock'); + + await expect( + runRemoteServe({ + config, + cwd: '/canonical/project', + resolvedContentDir: '/canonical/project', + nonce: NONCE, + deps: { + canonicalize: (path) => path, + readLock: () => null, + boot: async () => { + throw collision; + }, + writeStdout: (line) => stdout.push(line), + }, + }), + ).rejects.toMatchObject({ code: 'startup-failed', cause: collision }); + + expect(stdout).toEqual([]); + }); + + test('boots loopback-only on port 0, waits for readiness, and emits owned=true once', async () => { + const stdout: string[] = []; + const signalListeners = new Map void | Promise>(); + const removedSignals: NodeJS.Signals[] = []; + let destroyCalls = 0; + let stdinRegistrations = 0; + let stdinResumes = 0; + let resolveReady: (() => void) | undefined; + const ready = new Promise((resolve) => { + resolveReady = resolve; + }); + let bootOptions: Parameters[0] | undefined; + + const pending = runRemoteServe({ + config, + cwd: '/logical/project', + resolvedContentDir: '/canonical/project/content', + nonce: NONCE, + deps: { + canonicalize: () => '/canonical/project', + readLock: () => null, + boot: async (options) => { + bootOptions = options; + return { + port: 45678, + ready, + destroy: async () => { + destroyCalls += 1; + }, + }; + }, + writeStdout: (line) => stdout.push(line), + onceSignal: (signal, listener) => { + signalListeners.set(signal, listener); + }, + offSignal: (signal) => { + removedSignals.push(signal); + signalListeners.delete(signal); + }, + watchStdinForDisconnect: false, + onceStdin: () => { + stdinRegistrations += 1; + }, + resumeStdin: () => { + stdinResumes += 1; + }, + platform: 'linux', + pathSeparator: '/', + protocolVersion: 1, + runtimeVersion: '0.30.0', + }, + }); + + await Promise.resolve(); + expect(stdout).toEqual([]); + resolveReady?.(); + const result = await pending; + + expect(bootOptions).toMatchObject({ + cwd: '/canonical/project', + resolvedContentDir: '/canonical/project/content', + host: '127.0.0.1', + port: 0, + skipUiAutoSpawn: true, + serveContentAssets: true, + watcherBackend: 'chokidar', + }); + expect(result.owned).toBe(true); + expect(result.port).toBe(45678); + expect(destroyCalls).toBe(0); + expect(stdout).toHaveLength(1); + expect(parseReadyLine(stdout[0] ?? '').owned).toBe(true); + expect(stdinRegistrations).toBe(0); + expect(stdinResumes).toBe(0); + + await signalListeners.get('SIGTERM')?.(); + expect(destroyCalls).toBe(1); + expect(removedSignals).toEqual(['SIGINT', 'SIGTERM']); + + if (result.owned) await result.shutdown('SIGINT'); + expect(destroyCalls).toBe(1); + }); + + test('uses SSH stdin EOF as an idempotent owned-server lifetime signal', async () => { + const stdinListeners = new Map<'end' | 'close', () => void | Promise>(); + const removedStdinEvents: Array<'end' | 'close'> = []; + const removedSignals: NodeJS.Signals[] = []; + let destroyCalls = 0; + let resumeCalls = 0; + let pauseCalls = 0; + + const result = await runRemoteServe({ + config, + cwd: '/project', + resolvedContentDir: '/project', + nonce: NONCE, + deps: { + canonicalize: (path) => path, + readLock: () => null, + boot: async () => ({ + port: 45678, + ready: Promise.resolve(), + destroy: async () => { + destroyCalls += 1; + }, + }), + writeStdout: () => {}, + onceSignal: () => {}, + offSignal: (signal) => { + removedSignals.push(signal); + }, + watchStdinForDisconnect: true, + onceStdin: (event, listener) => { + stdinListeners.set(event, listener); + }, + offStdin: (event) => { + removedStdinEvents.push(event); + stdinListeners.delete(event); + }, + resumeStdin: () => { + resumeCalls += 1; + }, + pauseStdin: () => { + pauseCalls += 1; + }, + }, + }); + + expect(result.owned).toBe(true); + expect([...stdinListeners.keys()]).toEqual(['end', 'close']); + expect(resumeCalls).toBe(1); + + const onEnd = stdinListeners.get('end'); + const onClose = stdinListeners.get('close'); + expect(onEnd).toBeDefined(); + expect(onClose).toBeDefined(); + await Promise.all([onEnd?.(), onClose?.()]); + + expect(destroyCalls).toBe(1); + expect(removedSignals).toEqual(['SIGINT', 'SIGTERM']); + expect(removedStdinEvents).toEqual(['end', 'close']); + expect(pauseCalls).toBe(1); + expect(stdinListeners.size).toBe(0); + + if (result.owned) await result.shutdown('SIGTERM'); + expect(destroyCalls).toBe(1); + expect(pauseCalls).toBe(1); + }); + + test('destroys a partial boot when async readiness rejects', async () => { + let destroyCalls = 0; + const failure = new Error('watcher init failed'); + + await expect( + runRemoteServe({ + config, + cwd: '/project', + resolvedContentDir: '/project', + nonce: NONCE, + deps: { + canonicalize: (path) => path, + readLock: () => null, + boot: async () => ({ + port: 45678, + ready: Promise.reject(failure), + destroy: async () => { + destroyCalls += 1; + }, + }), + writeStdout: () => { + throw new Error('must not emit readiness'); + }, + }, + }), + ).rejects.toBe(failure); + + expect(destroyCalls).toBe(1); + }); + + test('does not hide cleanup failure after async readiness rejects', async () => { + const startupFailure = new Error('watcher init failed'); + const cleanupFailure = new Error('listener close failed'); + + try { + await runRemoteServe({ + config, + cwd: '/project', + resolvedContentDir: '/project', + nonce: NONCE, + deps: { + canonicalize: (path) => path, + readLock: () => null, + boot: async () => ({ + port: 45678, + ready: Promise.reject(startupFailure), + destroy: async () => { + throw cleanupFailure; + }, + }), + writeStdout: () => { + throw new Error('must not emit readiness'); + }, + }, + }); + throw new Error('expected runRemoteServe to reject'); + } catch (error) { + expect(error).toBeInstanceOf(AggregateError); + expect((error as AggregateError).errors).toEqual([startupFailure, cleanupFailure]); + } + }); +}); diff --git a/packages/cli/src/commands/remote.ts b/packages/cli/src/commands/remote.ts new file mode 100644 index 000000000..4ba04bb41 --- /dev/null +++ b/packages/cli/src/commands/remote.ts @@ -0,0 +1,443 @@ +/** Internal machine protocol used by Desktop's SSH companion. */ + +import { readFileSync, realpathSync } from 'node:fs'; +import { sep } from 'node:path'; +import { resolveConfigPath } from '@inkeep/open-knowledge-core/server'; +import { + type Config, + PinoLogger, + PROTOCOL_VERSION, + RUNTIME_VERSION, + readServerLock, + resolveLockDir, + ServerLockCollisionError, + type ServerLockMetadata, +} from '@inkeep/open-knowledge-server'; +import { parse as parseYaml } from 'yaml'; +import { + RemoteCompanionError, + type RemoteCompanionErrorCode, + type RemoteProjectInspection, +} from '../remote-project-bootstrap.ts'; +import { type BootedStartServer, bootStartServer } from './start.ts'; + +/** Stable stdout framing token consumed by remote-project launchers. */ +export const REMOTE_READY_PREFIX = 'OK_REMOTE_READY '; +export const REMOTE_INSPECT_PREFIX = 'OK_REMOTE_INSPECT '; +export const REMOTE_ERROR_PREFIX = 'OK_REMOTE_ERROR '; +const REMOTE_TERMINAL_CONSENT_PREFIX = 'OK_REMOTE_TERMINAL_CONSENT '; + +/** Version of the readiness-record JSON shape (independent of server protocol). */ +const REMOTE_READY_VERSION = 1 as const; + +/** Defensive cap so a malformed environment cannot produce an unbounded frame. */ +export const MAX_REMOTE_READY_LINE_BYTES = 16 * 1024; + +export type RemoteCompanionCommand = + | { readonly name: 'inspect' } + | { readonly name: 'serve'; readonly initialize: false; readonly waitForOwnerExit: boolean } + | { + readonly name: 'serve'; + readonly initialize: true; + readonly expectedPath: string; + readonly waitForOwnerExit: false; + } + | { readonly name: 'terminal-consent' }; + +export interface RemoteReadyPayload { + v: typeof REMOTE_READY_VERSION; + nonce: string; + port: number; + projectPath: string; + platform: NodeJS.Platform; + pathSeparator: string; + protocolVersion: number; + runtimeVersion: string; + capabilities: ['http', 'ws']; + owned: true; +} + +interface RemoteBootOptions { + config: Config; + cwd: string; + resolvedContentDir: string; + host: '127.0.0.1'; + port: number; + skipUiAutoSpawn: true; + serveContentAssets: true; + watcherBackend: 'chokidar'; + log: PinoLogger; +} + +interface RemoteBootHandle extends Pick {} + +type SignalListener = () => void | Promise; +type StdinLifecycleEvent = 'end' | 'close'; +type StdinLifecycleListener = () => void | Promise; + +type RemoteServeShutdownReason = NodeJS.Signals | 'stdin' | 'startup-error'; + +export interface RemoteServeDeps { + boot(options: RemoteBootOptions): Promise; + readLock(lockDir: string): ServerLockMetadata | null; + waitForLockRelease(lockDir: string): Promise; + lockDir(projectDir: string): string; + canonicalize(path: string): string; + writeStdout(value: string): void; + onceSignal(signal: NodeJS.Signals, listener: SignalListener): void; + offSignal(signal: NodeJS.Signals, listener: SignalListener): void; + watchStdinForDisconnect: boolean; + onceStdin(event: StdinLifecycleEvent, listener: StdinLifecycleListener): void; + offStdin(event: StdinLifecycleEvent, listener: StdinLifecycleListener): void; + resumeStdin(): void; + pauseStdin(): void; + setExitCode(code: number): void; + platform: NodeJS.Platform; + pathSeparator: string; + protocolVersion: number; + runtimeVersion: string; +} + +export interface RunRemoteServeOptions { + config: Config; + cwd: string; + resolvedContentDir: string; + nonce: string; + waitForOwnerExit?: boolean; + port?: number; + deps?: Partial; +} + +/** Machine frame shared by the full CLI and Desktop's bundled companion. */ +export function formatRemoteTerminalConsentLine(nonce: string, allowed: boolean): string { + return formatRemoteFrame(REMOTE_TERMINAL_CONSENT_PREFIX, { v: 1, nonce, allowed }); +} + +export function formatRemoteInspectLine( + nonce: string, + inspection: RemoteProjectInspection, +): string { + return formatRemoteFrame(REMOTE_INSPECT_PREFIX, { ...inspection, nonce }); +} + +export function formatRemoteErrorLine(nonce: string, code: RemoteCompanionErrorCode): string { + return formatRemoteFrame(REMOTE_ERROR_PREFIX, { v: 1, nonce, code }); +} + +export function parseRemoteCompanionNonce(args: readonly string[]): string { + const nonce = args[1]; + if (args[0] !== '--nonce' || nonce === undefined || !/^[A-Za-z0-9_-]{43}$/.test(nonce)) { + throw new RemoteCompanionError('startup-failed', 'Invalid remote companion nonce.'); + } + return nonce; +} + +export function parseRemoteCompanionCommand(args: readonly string[]): RemoteCompanionCommand { + if (args.length === 1 && args[0] === 'inspect') return { name: 'inspect' }; + if (args.length === 1 && args[0] === 'terminal-consent') return { name: 'terminal-consent' }; + if (args.length === 1 && args[0] === 'serve') { + return { name: 'serve', initialize: false, waitForOwnerExit: false }; + } + if (args.length === 2 && args[0] === 'serve' && args[1] === '--wait-for-owner-exit') { + return { name: 'serve', initialize: false, waitForOwnerExit: true }; + } + if ( + args.length === 4 && + args[0] === 'serve' && + args[1] === '--initialize' && + args[2] === '--expected-path' + ) { + return { + name: 'serve', + initialize: true, + expectedPath: decodeRemoteExpectedPath(args[3] ?? ''), + waitForOwnerExit: false, + }; + } + throw new RemoteCompanionError('startup-failed', 'Invalid remote companion command.'); +} + +function decodeRemoteExpectedPath(encoded: string): string { + if (encoded.length === 0 || encoded.length > MAX_REMOTE_READY_LINE_BYTES) { + throw new RemoteCompanionError('project-initialize-failed', 'Invalid expected project path.'); + } + if (!/^[A-Za-z0-9_-]+$/.test(encoded)) { + throw new RemoteCompanionError('project-initialize-failed', 'Invalid expected project path.'); + } + const decoded = Buffer.from(encoded, 'base64url').toString('utf8'); + if (decoded.includes('\0') || Buffer.from(decoded, 'utf8').toString('base64url') !== encoded) { + throw new RemoteCompanionError('project-initialize-failed', 'Invalid expected project path.'); + } + return decoded; +} + +export interface RemoteServeResult { + owned: true; + port: number; + shutdown: (reason: RemoteServeShutdownReason) => Promise; +} + +function isObject(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +/** + * Main-process terminal backstop for SSH projects. A missing local override + * permits the terminal; an unreadable or malformed override fails loudly. + */ +export function readRemoteTerminalConsent(projectDir: string): boolean { + const configPath = resolveConfigPath('project-local', projectDir); + let parsed: unknown; + try { + parsed = parseYaml(readFileSync(configPath, 'utf8')); + } catch (cause) { + if (isObject(cause) && cause.code === 'ENOENT') return true; + throw new RemoteCompanionError('config-invalid', 'Terminal configuration is invalid.', { + cause, + }); + } + if (parsed === null || parsed === undefined) return true; + if (!isObject(parsed)) { + throw new RemoteCompanionError('config-invalid', 'Terminal configuration is invalid.'); + } + if (parsed.terminal === undefined) return true; + if (!isObject(parsed.terminal)) { + throw new RemoteCompanionError('config-invalid', 'Terminal configuration is invalid.'); + } + if (parsed.terminal.enabled === undefined) return true; + if (typeof parsed.terminal.enabled !== 'boolean') { + throw new RemoteCompanionError('config-invalid', 'Terminal configuration is invalid.'); + } + return parsed.terminal.enabled; +} + +/** Wait through the server's persistence debounce when an opt-out is lifted. */ +export async function waitForRemoteTerminalConsent( + projectDir: string, + { + timeoutMs = 3_000, + intervalMs = 50, + read = readRemoteTerminalConsent, + sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)), + }: { + timeoutMs?: number; + intervalMs?: number; + read?: (projectDir: string) => boolean; + sleep?: (ms: number) => Promise; + } = {}, +): Promise { + const deadline = Date.now() + timeoutMs; + while (true) { + if (read(projectDir)) return true; + if (Date.now() >= deadline) return false; + await sleep(intervalMs); + } +} + +/** + * Whether a remote helper should use stdin as its SSH-session lifetime token. + * Interactive invocations keep normal signal-based foreground semantics. + */ +export function shouldWatchRemoteStdin( + env: NodeJS.ProcessEnv = process.env, + stdinIsTTY: boolean | undefined = process.stdin.isTTY, +): boolean { + const isSshSession = Boolean(env.SSH_CONNECTION || env.SSH_CLIENT || env.SSH_TTY); + return isSshSession && stdinIsTTY !== true; +} + +function defaultDeps(): RemoteServeDeps { + return { + boot: (options) => bootStartServer(options), + readLock: readServerLock, + waitForLockRelease: async (lockDir) => { + const deadline = Date.now() + 10_000; + while (Date.now() < deadline) { + await new Promise((resolve) => setTimeout(resolve, 100)); + if (readServerLock(lockDir) === null) return true; + } + return false; + }, + lockDir: resolveLockDir, + canonicalize: (path) => realpathSync.native(path), + writeStdout: (value) => { + process.stdout.write(value); + }, + onceSignal: (signal, listener) => { + process.once(signal, listener); + }, + offSignal: (signal, listener) => { + process.off(signal, listener); + }, + watchStdinForDisconnect: shouldWatchRemoteStdin(), + onceStdin: (event, listener) => { + process.stdin.once(event, listener); + }, + offStdin: (event, listener) => { + process.stdin.off(event, listener); + }, + resumeStdin: () => { + process.stdin.resume(); + }, + pauseStdin: () => { + process.stdin.pause(); + }, + setExitCode: (code) => { + process.exitCode = code; + }, + platform: process.platform, + pathSeparator: sep, + protocolVersion: PROTOCOL_VERSION, + runtimeVersion: RUNTIME_VERSION, + }; +} + +function formatRemoteFrame(prefix: string, payload: unknown): string { + const line = `${prefix}${JSON.stringify(payload)}\n`; + if (Buffer.byteLength(line, 'utf8') > MAX_REMOTE_READY_LINE_BYTES) { + throw new RemoteCompanionError('startup-failed', 'Remote companion frame is too large.'); + } + return line; +} + +/** Serialize one newline-delimited, size-bounded machine frame. */ +export function formatRemoteReadyLine(payload: RemoteReadyPayload): string { + return formatRemoteFrame(REMOTE_READY_PREFIX, payload); +} + +function readyPayload( + deps: RemoteServeDeps, + nonce: string, + projectPath: string, + port: number, +): RemoteReadyPayload { + return { + v: REMOTE_READY_VERSION, + nonce, + port, + projectPath, + platform: deps.platform, + pathSeparator: deps.pathSeparator, + protocolVersion: deps.protocolVersion, + runtimeVersion: deps.runtimeVersion, + capabilities: ['http', 'ws'], + owned: true, + }; +} + +/** + * Start the project server and emit exactly one readiness frame. The server + * remains foreground-owned by this process so the SSH session is its lifetime + * token. + */ +export async function runRemoteServe(options: RunRemoteServeOptions): Promise { + const deps: RemoteServeDeps = { ...defaultDeps(), ...options.deps }; + const projectPath = deps.canonicalize(options.cwd); + const lockDir = deps.lockDir(projectPath); + const requestedPort = options.port ?? 0; + + const existing = deps.readLock(lockDir); + if (existing !== null) { + if (!options.waitForOwnerExit || !(await deps.waitForLockRelease(lockDir))) { + throw new RemoteCompanionError( + 'startup-failed', + options.waitForOwnerExit + ? 'The previous OpenKnowledge server did not exit in time.' + : 'Another OpenKnowledge server already owns this project.', + ); + } + } + + let booted: RemoteBootHandle; + try { + booted = await deps.boot({ + config: options.config, + cwd: projectPath, + resolvedContentDir: options.resolvedContentDir, + host: '127.0.0.1', + port: requestedPort, + skipUiAutoSpawn: true, + serveContentAssets: true, + watcherBackend: 'chokidar', + // stdout is a machine-protocol channel for this command. Keep server + // diagnostics silent there; the entry point maps failures to a bounded + // code-only error frame. + log: new PinoLogger('remote-serve', { options: { level: 'silent' } }), + }); + } catch (err) { + if (err instanceof ServerLockCollisionError) { + throw new RemoteCompanionError( + 'startup-failed', + 'Another OpenKnowledge server won the project lock race.', + { cause: err }, + ); + } + throw err; + } + + try { + await booted.ready; + } catch (startupError) { + try { + await booted.destroy(); + } catch (cleanupError) { + throw new AggregateError( + [startupError, cleanupError], + 'Remote server startup and cleanup both failed.', + ); + } + throw startupError; + } + + let shutdownPromise: Promise | null = null; + const onSigint: SignalListener = () => shutdown('SIGINT'); + const onSigterm: SignalListener = () => shutdown('SIGTERM'); + const onStdinEnd: StdinLifecycleListener = () => shutdown('stdin'); + const onStdinClose: StdinLifecycleListener = () => shutdown('stdin'); + let signalHandlersAttached = false; + let stdinHandlersAttached = false; + const removeLifecycleHandlers = () => { + if (signalHandlersAttached) { + signalHandlersAttached = false; + deps.offSignal('SIGINT', onSigint); + deps.offSignal('SIGTERM', onSigterm); + } + if (stdinHandlersAttached) { + stdinHandlersAttached = false; + deps.offStdin('end', onStdinEnd); + deps.offStdin('close', onStdinClose); + deps.pauseStdin(); + } + }; + const shutdown = (_reason: RemoteServeShutdownReason): Promise => { + shutdownPromise ??= booted + .destroy() + .catch(() => { + deps.setExitCode(1); + deps.writeStdout(formatRemoteErrorLine(options.nonce, 'startup-failed')); + }) + .finally(removeLifecycleHandlers); + return shutdownPromise; + }; + + const payload = readyPayload(deps, options.nonce, projectPath, booted.port); + try { + signalHandlersAttached = true; + deps.onceSignal('SIGINT', onSigint); + deps.onceSignal('SIGTERM', onSigterm); + if (deps.watchStdinForDisconnect) { + stdinHandlersAttached = true; + deps.onceStdin('end', onStdinEnd); + deps.onceStdin('close', onStdinClose); + // A non-TTY stdin is paused by default. Flowing it is required for an + // SSH channel EOF to surface as `end`; `close` covers abrupt teardown. + deps.resumeStdin(); + } + deps.writeStdout(formatRemoteReadyLine(payload)); + } catch (err) { + await shutdown('startup-error'); + throw err; + } + + return { owned: true, port: booted.port, shutdown }; +} diff --git a/packages/cli/src/commands/start.test.ts b/packages/cli/src/commands/start.test.ts index 7ee52e3c7..429f70142 100644 --- a/packages/cli/src/commands/start.test.ts +++ b/packages/cli/src/commands/start.test.ts @@ -1,6 +1,13 @@ import { afterEach, beforeEach, describe, expect, test } from 'bun:test'; import type { spawn as NativeSpawn } from 'node:child_process'; -import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'; +import { + existsSync, + mkdirSync, + readFileSync, + realpathSync, + symlinkSync, + writeFileSync, +} from 'node:fs'; import { mkdtemp, rm } from 'node:fs/promises'; import { request as httpRequest } from 'node:http'; import { hostname, tmpdir } from 'node:os'; @@ -9,6 +16,7 @@ import { setTimeout as wait } from 'node:timers/promises'; import { LOCAL_DIR } from '@inkeep/open-knowledge-core'; import { type Config, ConfigSchema } from '@inkeep/open-knowledge-server'; import { + assertResolvedContentDirectory, awaitUiSiblingPort, type BootedStartServer, bootStartServer, @@ -55,6 +63,25 @@ describe('resolveHost', () => { }); }); +describe('assertResolvedContentDirectory', () => { + test('rejects a directory replaced by a symlink after remote validation', async () => { + const root = await mkdtemp(resolve(tmpdir(), 'ok-content-proof-')); + const contentDir = resolve(root, 'content'); + const outsideDir = resolve(root, 'outside'); + mkdirSync(contentDir); + mkdirSync(outsideDir); + const validatedPath = realpathSync.native(contentDir); + + await rm(contentDir, { recursive: true }); + symlinkSync(outsideDir, contentDir, 'dir'); + + expect(() => assertResolvedContentDirectory(validatedPath)).toThrow( + 'The validated content directory changed before server startup.', + ); + await rm(root, { recursive: true, force: true }); + }); +}); + describe('formatShutdownNotice', () => { test('SIGINT includes the headline, the wait notice, and the force-quit hint', () => { const lines = formatShutdownNotice('SIGINT'); diff --git a/packages/cli/src/commands/start.ts b/packages/cli/src/commands/start.ts index add4d2876..33744f0fe 100644 --- a/packages/cli/src/commands/start.ts +++ b/packages/cli/src/commands/start.ts @@ -20,7 +20,14 @@ import { type spawn as NativeSpawn, spawn as nativeSpawn, } from 'node:child_process'; -import { closeSync, existsSync as fsExistsSync, mkdirSync as fsMkdirSync, openSync } from 'node:fs'; +import { + closeSync, + existsSync as fsExistsSync, + mkdirSync as fsMkdirSync, + lstatSync, + openSync, + realpathSync, +} from 'node:fs'; import { rm } from 'node:fs/promises'; import type { Server as HttpServer } from 'node:http'; import { basename, join } from 'node:path'; @@ -631,6 +638,10 @@ interface BootStartServerOptions { skipAutoInit?: boolean; /** Skip the auto-spawn-of-ok-ui-sibling step entirely (does not call `spawnOkUi`). */ skipUiAutoSpawn?: boolean; + /** Pin the server filesystem watcher. Remote companions use bundled chokidar. */ + watcherBackend?: 'parcel' | 'chokidar'; + /** Already-canonical content root supplied by the remote confinement gate. */ + resolvedContentDir?: string; /** Override for `spawnOkUi`'s underlying spawn — passed through to it. */ spawn?: typeof NativeSpawn; /** Override idle-shutdown threshold; default 30 min. Tests use small values. */ @@ -769,6 +780,19 @@ export interface BootedStartServer { resolvedUiPort: number | null; } +/** Re-check the remote confinement proof immediately before server boot. */ +export function assertResolvedContentDirectory(contentDir: string): void { + let canonicalPath: string; + try { + canonicalPath = realpathSync.native(contentDir); + } catch (cause) { + throw new Error('The validated content directory no longer exists.', { cause }); + } + if (canonicalPath !== contentDir || !lstatSync(canonicalPath).isDirectory()) { + throw new Error('The validated content directory changed before server startup.'); + } +} + /** * Boot the collab server end-to-end and return a handle. Pure of process-level * concerns (signal handlers, banner, browser-open, exit codes) so integration @@ -893,8 +917,11 @@ export async function bootStartServer(opts: BootStartServerOptions): Promise { expect(exitCode).toBe(0); expect(stderr).not.toContain('Using OpenKnowledge project at'); }, 30_000); + + test('does not expose Desktop companion commands through the public CLI', () => { + const { exitCode, stdout, stderr } = spawnCli(['remote']); + + expect(exitCode).not.toBe(0); + expect(stdout).toBe(''); + expect(stderr).toContain('too many arguments'); + }); }); diff --git a/packages/cli/src/remote-companion.ts b/packages/cli/src/remote-companion.ts new file mode 100644 index 000000000..3b2bf3f1b --- /dev/null +++ b/packages/cli/src/remote-companion.ts @@ -0,0 +1,98 @@ +#!/usr/bin/env node + +/** + * Minimal entry point installed by Desktop on an SSH machine. + * + * This is intentionally not a second user-facing `ok` installation. Desktop + * content-addresses the bundled file under `~/.ok/remote/servers/` and invokes + * it through the remote machine's existing Node.js runtime. Keeping the entry + * to machine-only commands avoids changing PATH or installing npm + * packages on the user's behalf. + */ + +import { + formatRemoteErrorLine, + formatRemoteInspectLine, + formatRemoteTerminalConsentLine, + parseRemoteCompanionCommand, + parseRemoteCompanionNonce, + runRemoteServe, + waitForRemoteTerminalConsent, +} from './commands/remote.ts'; +import { loadConfig } from './config/loader.ts'; +import { PACKAGE_VERSION } from './constants.ts'; +import { + inspectRemoteProject, + prepareRemoteProject, + RemoteCompanionError, + validateRemoteContentDirectory, +} from './remote-project-bootstrap.ts'; + +// Remote editor sessions must not rewrite editor integrations or global skill +// projections on the SSH host merely because a project was opened. The normal +// server boot path already owns this documented opt-out. +process.env.OK_RECLAIM_DISABLE = '1'; + +async function main(): Promise { + const args = process.argv.slice(2); + const nonce = parseRemoteCompanionNonce(args); + responseNonce = nonce; + const command = parseRemoteCompanionCommand(args.slice(2)); + + if (command.name === 'inspect') { + process.stdout.write(formatRemoteInspectLine(nonce, inspectRemoteProject(process.cwd()))); + return; + } + + if (command.name === 'serve') { + const projectRoot = prepareRemoteProject( + process.cwd(), + command.initialize ? command.expectedPath : undefined, + ); + process.chdir(projectRoot); + const config = (() => { + try { + return loadConfig(projectRoot).config; + } catch (cause) { + throw new RemoteCompanionError('config-invalid', 'Project configuration is invalid.', { + cause, + }); + } + })(); + const resolvedContentDir = validateRemoteContentDirectory(projectRoot, config.content.dir); + await runRemoteServe({ + config, + cwd: projectRoot, + resolvedContentDir, + nonce, + waitForOwnerExit: command.waitForOwnerExit, + deps: { runtimeVersion: PACKAGE_VERSION }, + }); + return; + } + + if (command.name === 'terminal-consent') { + const inspection = inspectRemoteProject(process.cwd()); + if (!inspection.initialized) { + throw new RemoteCompanionError( + 'project-uninitialized', + 'The selected folder is not an OpenKnowledge project.', + ); + } + const allowed = await waitForRemoteTerminalConsent(inspection.projectPath); + process.stdout.write(formatRemoteTerminalConsentLine(nonce, allowed)); + } +} + +let responseNonce: string | undefined; +main().catch((error: unknown) => { + if (responseNonce !== undefined) { + process.stdout.write( + formatRemoteErrorLine( + responseNonce, + error instanceof RemoteCompanionError ? error.code : 'startup-failed', + ), + ); + } + process.exitCode = 1; +}); diff --git a/packages/cli/src/remote-project-bootstrap.test.ts b/packages/cli/src/remote-project-bootstrap.test.ts new file mode 100644 index 000000000..1691b120e --- /dev/null +++ b/packages/cli/src/remote-project-bootstrap.test.ts @@ -0,0 +1,231 @@ +import { afterEach, describe, expect, test } from 'bun:test'; +import { + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + realpathSync, + rmSync, + symlinkSync, + writeFileSync, +} from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { + inspectRemoteProject, + prepareRemoteProject, + RemoteCompanionError, + validateRemoteContentDirectory, +} from './remote-project-bootstrap.ts'; + +const roots: string[] = []; + +function temporaryDirectory(): string { + const root = mkdtempSync(join(tmpdir(), 'ok-remote-project-')); + roots.push(root); + return root; +} + +afterEach(() => { + for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }); +}); + +describe('remote project inspection and initialization', () => { + test('inspection reports the canonical selected folder without writing a project', () => { + const root = temporaryDirectory(); + + expect(inspectRemoteProject(root)).toEqual({ + v: 1, + selectedPath: realpathSync.native(root), + projectPath: realpathSync.native(root), + initialized: false, + }); + expect(existsSync(join(root, '.ok'))).toBe(false); + }); + + test('does not mistake per-user remote support for an incomplete ancestor project', () => { + const outer = temporaryDirectory(); + const home = join(outer, 'home'); + const selected = join(home, 'projects', 'wiki'); + mkdirSync(join(outer, '.ok')); + writeFileSync(join(outer, '.ok', 'config.yml'), 'content:\n dir: .\n'); + mkdirSync(join(home, '.ok', 'remote'), { recursive: true }); + mkdirSync(selected, { recursive: true }); + + expect(inspectRemoteProject(selected, home)).toEqual({ + v: 1, + selectedPath: realpathSync.native(selected), + projectPath: realpathSync.native(selected), + initialized: false, + }); + }); + + test('still inherits a complete project rooted at the remote home directory', () => { + const home = temporaryDirectory(); + const selected = join(home, 'docs'); + mkdirSync(join(home, '.ok'), { recursive: true }); + writeFileSync(join(home, '.ok', 'config.yml'), 'content:\n dir: .\n'); + mkdirSync(selected); + + expect(inspectRemoteProject(home, home)).toEqual({ + v: 1, + selectedPath: realpathSync.native(home), + projectPath: realpathSync.native(home), + initialized: true, + }); + expect(inspectRemoteProject(selected, home)).toEqual({ + v: 1, + selectedPath: realpathSync.native(selected), + projectPath: realpathSync.native(home), + initialized: true, + }); + }); + + test('treats a nested metadata directory without config.yml as a non-root', () => { + const home = temporaryDirectory(); + const selected = join(home, 'work'); + mkdirSync(join(selected, '.ok'), { recursive: true }); + writeFileSync(join(selected, '.ok', 'frontmatter.yml'), 'title: Work\n'); + + expect(inspectRemoteProject(selected, home)).toEqual({ + v: 1, + selectedPath: realpathSync.native(selected), + projectPath: realpathSync.native(selected), + initialized: false, + }); + }); + + test('inspection rejects a symlinked project configuration instead of walking past it', () => { + const root = temporaryDirectory(); + const outside = temporaryDirectory(); + mkdirSync(join(root, '.ok')); + writeFileSync(join(outside, 'config.yml'), 'content:\n dir: .\n'); + symlinkSync(join(outside, 'config.yml'), join(root, '.ok', 'config.yml')); + + expect(() => inspectRemoteProject(root)).toThrow( + expect.objectContaining({ code: 'config-invalid' }), + ); + expect(existsSync(join(root, '.okignore'))).toBe(false); + }); + + test('nested folder metadata inherits the nearest enclosing project', () => { + const root = temporaryDirectory(); + prepareRemoteProject(root, realpathSync.native(root)); + const nested = join(root, 'nested'); + mkdirSync(join(nested, '.ok'), { recursive: true }); + writeFileSync(join(nested, '.ok', 'frontmatter.yml'), 'title: Nested\n'); + + expect(inspectRemoteProject(nested)).toEqual({ + v: 1, + selectedPath: realpathSync.native(nested), + projectPath: realpathSync.native(root), + initialized: true, + }); + }); + + test('serve refuses an unconfigured folder without explicit initialization', () => { + const root = temporaryDirectory(); + + expect(() => prepareRemoteProject(root)).toThrow(RemoteCompanionError); + try { + prepareRemoteProject(root); + } catch (error) { + expect(error).toMatchObject({ code: 'project-uninitialized' }); + } + expect(existsSync(join(root, '.ok'))).toBe(false); + }); + + test('explicit initialization writes only the normal project scaffold', () => { + const root = temporaryDirectory(); + + expect(prepareRemoteProject(root, realpathSync.native(root))).toBe(realpathSync.native(root)); + expect(existsSync(join(root, '.ok', 'config.yml'))).toBe(true); + expect(existsSync(join(root, '.ok', '.gitignore'))).toBe(true); + expect(existsSync(join(root, '.okignore'))).toBe(true); + }); + + test('preserves an existing project configuration', () => { + const root = temporaryDirectory(); + prepareRemoteProject(root, realpathSync.native(root)); + const configPath = join(root, '.ok', 'config.yml'); + writeFileSync(configPath, 'terminal:\n enabled: false\n'); + + expect(prepareRemoteProject(root)).toBe(realpathSync.native(root)); + expect(readFileSync(configPath, 'utf8')).toBe('terminal:\n enabled: false\n'); + }); + + test('uses the nearest enclosing project instead of initializing a nested folder', () => { + const root = temporaryDirectory(); + prepareRemoteProject(root, realpathSync.native(root)); + const nested = join(root, 'docs', 'guides'); + mkdirSync(nested, { recursive: true }); + + expect(inspectRemoteProject(nested)).toEqual({ + v: 1, + selectedPath: realpathSync.native(nested), + projectPath: realpathSync.native(root), + initialized: true, + }); + expect(prepareRemoteProject(nested)).toBe(realpathSync.native(root)); + expect(existsSync(join(nested, '.ok'))).toBe(false); + }); + + test('rejects initialization when the selected folder differs from the confirmed path', () => { + const root = temporaryDirectory(); + const other = temporaryDirectory(); + + expect(() => prepareRemoteProject(root, realpathSync.native(other))).toThrow( + expect.objectContaining({ code: 'project-initialize-failed' }), + ); + expect(existsSync(join(root, '.ok'))).toBe(false); + expect(existsSync(join(other, '.ok'))).toBe(false); + }); +}); + +describe('validateRemoteContentDirectory', () => { + test('accepts existing directories inside the project', () => { + const root = temporaryDirectory(); + const docs = join(root, 'docs'); + mkdirSync(docs); + + expect(validateRemoteContentDirectory(root, '.')).toBe(realpathSync.native(root)); + expect(validateRemoteContentDirectory(root, 'docs')).toBe(realpathSync.native(docs)); + }); + + test('rejects missing directories instead of creating through an untrusted ancestor', () => { + const root = temporaryDirectory(); + + expect(() => validateRemoteContentDirectory(root, 'new/nested')).toThrow( + expect.objectContaining({ code: 'content-dir-outside-project' }), + ); + expect(existsSync(join(root, 'new'))).toBe(false); + }); + + test.each(['..', '../outside'])('rejects lexical project escape %j', (configuredDir) => { + const root = temporaryDirectory(); + + expect(() => validateRemoteContentDirectory(root, configuredDir)).toThrow( + expect.objectContaining({ code: 'content-dir-outside-project' }), + ); + }); + + test('rejects an in-project symlink whose target is outside the project', () => { + const root = temporaryDirectory(); + const outside = temporaryDirectory(); + symlinkSync(outside, join(root, 'linked-content'), 'dir'); + + expect(() => validateRemoteContentDirectory(root, 'linked-content')).toThrow( + expect.objectContaining({ code: 'content-dir-outside-project' }), + ); + }); + + test('allows an in-project symlink whose canonical target remains inside', () => { + const root = temporaryDirectory(); + const docs = join(root, 'docs'); + mkdirSync(docs); + symlinkSync(docs, join(root, 'linked-content'), 'dir'); + + expect(validateRemoteContentDirectory(root, 'linked-content')).toBe(realpathSync.native(docs)); + }); +}); diff --git a/packages/cli/src/remote-project-bootstrap.ts b/packages/cli/src/remote-project-bootstrap.ts new file mode 100644 index 000000000..9f90d9476 --- /dev/null +++ b/packages/cli/src/remote-project-bootstrap.ts @@ -0,0 +1,184 @@ +import { lstatSync, realpathSync } from 'node:fs'; +import { homedir } from 'node:os'; +import { dirname, isAbsolute, relative, resolve, sep } from 'node:path'; + +import { initContent } from '@inkeep/open-knowledge-server'; + +import { PACKAGE_VERSION } from './constants.ts'; + +export type RemoteCompanionErrorCode = + | 'project-uninitialized' + | 'project-initialize-failed' + | 'config-invalid' + | 'content-dir-outside-project' + | 'startup-failed'; + +export class RemoteCompanionError extends Error { + constructor( + readonly code: RemoteCompanionErrorCode, + message: string, + options?: ErrorOptions, + ) { + super(message, options); + this.name = 'RemoteCompanionError'; + } +} + +export interface RemoteProjectInspection { + readonly v: 1; + readonly selectedPath: string; + readonly projectPath: string; + readonly initialized: boolean; +} + +function isNotFound(cause: unknown): boolean { + return typeof cause === 'object' && cause !== null && 'code' in cause && cause.code === 'ENOENT'; +} + +function isRemoteProjectRoot(path: string): boolean { + const okDirectory = resolve(path, '.ok'); + try { + const stat = lstatSync(okDirectory); + if (stat.isSymbolicLink() || !stat.isDirectory()) { + throw new RemoteCompanionError('config-invalid', 'Project configuration is invalid.'); + } + } catch (cause) { + if (cause instanceof RemoteCompanionError) throw cause; + if (isNotFound(cause)) return false; + throw new RemoteCompanionError('config-invalid', 'Project configuration cannot be read.', { + cause, + }); + } + + try { + const stat = lstatSync(resolve(okDirectory, 'config.yml')); + if (stat.isSymbolicLink() || !stat.isFile()) { + throw new RemoteCompanionError('config-invalid', 'Project configuration is invalid.'); + } + return true; + } catch (cause) { + if (cause instanceof RemoteCompanionError) throw cause; + if (isNotFound(cause)) return false; + throw new RemoteCompanionError('config-invalid', 'Project configuration cannot be read.', { + cause, + }); + } +} + +function findRemoteProjectRoot(selectedPath: string, homePath: string): string | null { + let current = selectedPath; + while (true) { + const atHome = current === homePath; + if (isRemoteProjectRoot(current)) return realpathSync.native(current); + // ~/.ok also owns per-user support such as remote companions. A missing + // project config there is not a marker, and lookup must never escape into + // another home-level namespace. + if (atHome) return null; + const parent = dirname(current); + if (parent === current) return null; + current = parent; + } +} + +function isWithin(root: string, candidate: string): boolean { + const rel = relative(root, candidate); + return rel === '' || (rel !== '..' && !rel.startsWith(`..${sep}`) && !isAbsolute(rel)); +} + +/** Resolve the selected folder and its nearest existing OpenKnowledge project. */ +export function inspectRemoteProject( + cwd: string, + homeDirectory: string = homedir(), +): RemoteProjectInspection { + const selectedPath = realpathSync.native(cwd); + const projectPath = findRemoteProjectRoot(selectedPath, realpathSync.native(homeDirectory)); + if (projectPath === null) { + return { v: 1, selectedPath, projectPath: selectedPath, initialized: false }; + } + return { + v: 1, + selectedPath, + projectPath, + initialized: true, + }; +} + +/** + * Resolve an existing project, or initialize exactly the selected folder when + * the caller has explicitly confirmed that write. + */ +export function prepareRemoteProject(cwd: string, expectedSelectedPath?: string): string { + const inspection = inspectRemoteProject(cwd); + const initialize = expectedSelectedPath !== undefined; + if (initialize && inspection.selectedPath !== expectedSelectedPath) { + throw new RemoteCompanionError( + 'project-initialize-failed', + 'The selected folder changed after confirmation.', + ); + } + if (inspection.initialized) return inspection.projectPath; + if (!initialize) { + throw new RemoteCompanionError( + 'project-uninitialized', + 'The selected folder is not an OpenKnowledge project.', + ); + } + + try { + initContent(inspection.selectedPath, { packageVersion: PACKAGE_VERSION }); + } catch (cause) { + throw new RemoteCompanionError( + 'project-initialize-failed', + 'The selected folder could not be initialized.', + { cause }, + ); + } + if (!isRemoteProjectRoot(inspection.selectedPath)) { + throw new RemoteCompanionError( + 'project-initialize-failed', + 'Initialization did not create a valid OpenKnowledge project.', + ); + } + return inspection.selectedPath; +} + +/** + * Prove that the configured content root stays inside the project before the + * normal boot path starts filesystem watchers. Remote projects require an + * existing directory so no recursive mkdir can follow an ancestor swapped to + * a symlink between validation and boot. + */ +export function validateRemoteContentDirectory( + projectRoot: string, + configuredContentDir: string, +): string { + const canonicalProjectRoot = realpathSync.native(projectRoot); + const resolvedContentDir = resolve(canonicalProjectRoot, configuredContentDir); + if (!isWithin(canonicalProjectRoot, resolvedContentDir)) { + throw new RemoteCompanionError( + 'content-dir-outside-project', + 'The configured content directory is outside the project.', + ); + } + + let canonicalContentDir: string; + try { + canonicalContentDir = realpathSync.native(resolvedContentDir); + if (!lstatSync(canonicalContentDir).isDirectory()) { + throw new Error('Content path is not a directory.'); + } + } catch (cause) { + throw new RemoteCompanionError( + 'content-dir-outside-project', + 'The configured content directory must be an existing directory.', + { cause }, + ); + } + if (!isWithin(canonicalProjectRoot, canonicalContentDir)) { + throw new RemoteCompanionError( + 'content-dir-outside-project', + 'The configured content directory resolves outside the project.', + ); + } + return canonicalContentDir; +} diff --git a/packages/cli/tsdown.remote.config.ts b/packages/cli/tsdown.remote.config.ts new file mode 100644 index 000000000..e34bdb4e4 --- /dev/null +++ b/packages/cli/tsdown.remote.config.ts @@ -0,0 +1,47 @@ +import { createRequire } from 'node:module'; +import { defineConfig } from 'tsdown'; + +const jsoncParserEsmEntry = createRequire(import.meta.url).resolve('jsonc-parser/lib/esm/main.js'); + +/** + * Desktop uploads this artifact to macOS and Linux SSH hosts, so it must be a + * single architecture-neutral JavaScript file with no adjacent chunk graph. + * Native integrations stay external so the artifact remains portable across + * CPU architectures; all pure-JS runtime dependencies, including chokidar, + * are inlined. + */ +export default defineConfig({ + entry: { 'remote-companion': 'src/remote-companion.ts' }, + outDir: 'dist', + unbundle: false, + format: 'esm', + dts: false, + clean: false, + minify: true, + hash: false, + outputOptions: { + codeSplitting: false, + // This banner runs before bundled server modules create their loggers. + // Stdout stays a frame-only channel; normal diagnostics still go to the + // remote machine's on-disk log sink. + banner: "process.env.OK_CONSOLE_LEVEL = 'silent';", + }, + plugins: [ + { + name: 'jsonc-parser-esm-entry', + resolveId(id) { + return id === 'jsonc-parser' ? jsoncParserEsmEntry : null; + }, + }, + ], + deps: { + neverBundle: [ + '@parcel/watcher', + '@napi-rs/keyring', + '@inkeep/open-knowledge-native-config', + '@mongodb-js/zstd', + 'node-liblzma', + ], + alwaysBundle: [/.*/], + }, +}); diff --git a/packages/core/src/desktop-bridge.ts b/packages/core/src/desktop-bridge.ts index 5a1809d45..8c3f270ba 100644 --- a/packages/core/src/desktop-bridge.ts +++ b/packages/core/src/desktop-bridge.ts @@ -17,6 +17,13 @@ import type { WorktreeListResult, } from './git/worktree-selector-model.ts'; import type { TerminalCli } from './handoff/terminal-launch.ts'; +import type { + RemoteDirectoryListing, + RemoteProjectInfo, + SshConnectionTestResult, + SshMachine, + SshMachineDraft, +} from './remote-project.ts'; import type { LocalOpOkInitResponse } from './schemas/api/local-op.ts'; import type { BranchInfoResponse, @@ -44,6 +51,8 @@ interface OkDesktopConfig { readonly projectPath: string; /** Display name for the project (usually basename of projectPath). */ readonly projectName: string; + /** SSH metadata for a remote window; null for local/navigator windows. */ + readonly remote: RemoteProjectInfo | null; /** Render mode — `navigator` renders the Project Navigator, `editor` renders the doc editor, `terminal` renders the standalone terminal window. */ readonly mode: OkDesktopMode; } @@ -862,7 +871,10 @@ export type OkServerRestartOutcome = /** Result of `terminal.create`. Canonical JSDoc in `bridge-contract.ts`; mirrored verbatim (drift-tested). */ export type OkPtyCreateResult = | { readonly ok: true; readonly ptyId: string } - | { readonly ok: false; readonly reason: 'no-project' | 'not-consented' }; + | { + readonly ok: false; + readonly reason: 'no-project' | 'not-consented' | 'remote-unavailable'; + }; /** Entry of `terminal.list`. Canonical JSDoc in `bridge-contract.ts`; mirrored verbatim (drift-tested). */ export interface OkPtyListEntry { @@ -899,7 +911,7 @@ export interface OkPtyExit { */ export interface ClaudeReadiness { readonly claude: 'present' | 'not-found' | 'unknown'; - readonly mcp: 'wired' | 'needs-rewire'; + readonly mcp: 'wired' | 'needs-rewire' | 'unknown'; /** True when the project's own `open-knowledge` `.mcp.json` entry is verified * to be OK's canonical managed server (cli `isOwnManagedEntry`), so the docked * terminal may pre-approve it on Claude launch instead of re-showing Claude's @@ -1204,6 +1216,16 @@ export interface OkDesktopBridge { writeText(text: string): Promise; }; + /** Saved-machine management and SSH-backed project opening. */ + remote: { + listMachines(): Promise; + saveMachine(machine: SshMachineDraft): Promise; + removeMachine(machineId: string): Promise; + testMachine(machineId: string): Promise; + listDirectories(request: { machineId: string; path: string }): Promise; + openProject(request: { machineId: string; path: string }): Promise; + }; + /** * Project-management surface consumed by the Navigator component. * `listRecent` reads the LRU-capped recent list from app state; `open` diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 9e51fac12..8fc7a5668 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -1106,6 +1106,17 @@ export { InstalledSkillsSchema, parseInstalledSkills, } from './installed-skills/schema.ts'; +export { + isRemoteProjectKey, + isSafeSshDestination, + type RemoteDirectoryEntry, + type RemoteDirectoryListing, + type RemoteProjectInfo, + remoteProjectKey, + type SshConnectionTestResult, + type SshMachine, + type SshMachineDraft, +} from './remote-project.ts'; export { createWorkspaceSearchCorpus, createWorkspaceSearchDocument, diff --git a/packages/core/src/remote-project.test.ts b/packages/core/src/remote-project.test.ts new file mode 100644 index 000000000..3adeca03d --- /dev/null +++ b/packages/core/src/remote-project.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, test } from 'bun:test'; +import { isRemoteProjectKey, isSafeSshDestination, remoteProjectKey } from './remote-project'; + +describe('remoteProjectKey', () => { + test('scopes a path to its machine and escapes delimiter characters', () => { + expect(remoteProjectKey('machine:one', '/srv/a project')).toBe( + 'ssh:machine%3Aone:%2Fsrv%2Fa%20project', + ); + expect(remoteProjectKey('machine-two', '/srv/a project')).not.toBe( + remoteProjectKey('machine:one', '/srv/a project'), + ); + }); + + test('recognizes only opaque SSH project keys', () => { + expect(isRemoteProjectKey(remoteProjectKey('machine', '/srv/project'))).toBe(true); + expect(isRemoteProjectKey('ssh:')).toBe(false); + expect(isRemoteProjectKey('ssh:machine')).toBe(false); + expect(isRemoteProjectKey('ssh:machine:%broken')).toBe(false); + expect(isRemoteProjectKey('/Users/me/ssh:project')).toBe(false); + expect(isRemoteProjectKey('https://example.com/project')).toBe(false); + }); +}); + +describe('isSafeSshDestination', () => { + test('accepts ordinary aliases, hostnames, and user-qualified hosts', () => { + expect(isSafeSshDestination('build-box')).toBe(true); + expect(isSafeSshDestination('dev.example.com')).toBe(true); + expect(isSafeSshDestination('developer@build_box')).toBe(true); + }); + + test('rejects shell expansion, globs, IPv6 literals, and option-like hosts', () => { + for (const value of [ + '-proxy', + 'host name', + // biome-ignore lint/suspicious/noTemplateCurlyInString: literal shell-injection payload. + 'x;touch${IFS}/tmp/pwn', + '$(touch-pwn)', + 'host|command', + 'wild*card', + '[::1]', + ]) { + expect(isSafeSshDestination(value)).toBe(false); + } + }); +}); diff --git a/packages/core/src/remote-project.ts b/packages/core/src/remote-project.ts new file mode 100644 index 000000000..8cca2544e --- /dev/null +++ b/packages/core/src/remote-project.ts @@ -0,0 +1,90 @@ +/** + * Browser-safe data contracts for SSH-backed desktop projects. + * + * Authentication remains entirely in the desktop main process and the + * system OpenSSH client. These records intentionally contain no passwords, + * private keys, tokens, or arbitrary SSH arguments. + */ + +/** A saved SSH destination, usually a `Host` alias from `~/.ssh/config`. */ +export interface SshMachine { + /** Stable opaque identifier used to scope project/window identity. */ + id: string; + /** User-facing label, for example "Build box". */ + name: string; + /** OpenSSH destination or config alias, for example `dev@example.com`. */ + host: string; + /** Optional SSH port override. Omitted means OpenSSH config/defaults win. */ + port?: number; +} + +/** Renderer-to-main input used when creating or editing a saved machine. */ +export interface SshMachineDraft { + id?: string; + name: string; + host: string; + port?: number; +} + +/** + * Whether a renderer-supplied OpenSSH destination stays safe when ssh_config + * expands `%h`/`%n` inside shell-backed directives such as ProxyCommand or + * Match exec. IPv6 literals should use a safe Host alias instead. + */ +export function isSafeSshDestination(value: string): boolean { + return /^(?:[A-Za-z0-9._-]+@)?[A-Za-z0-9_][A-Za-z0-9._-]*$/.test(value); +} + +/** A folder returned by the remote folder browser. */ +export interface RemoteDirectoryEntry { + name: string; + path: string; +} + +/** Canonical remote directory listing returned by desktop main. */ +export interface RemoteDirectoryListing { + path: string; + parentPath: string | null; + directories: RemoteDirectoryEntry[]; +} + +/** Remote project metadata frozen into an SSH-backed editor window. */ +export interface RemoteProjectInfo { + kind: 'ssh'; + machineId: string; + machineName: string; + /** Canonical path on the remote host. */ + path: string; + /** Supported POSIX remote runtime, not the local desktop platform. */ + platform: 'darwin' | 'linux'; + /** Remote projects are supported only on POSIX hosts. */ + pathSeparator: '/'; +} + +/** Renderer-safe connection-test result. Raw SSH output never crosses IPC. */ +export type SshConnectionTestResult = { ok: true } | { ok: false; error: string }; + +/** + * Build a machine-scoped identity for an SSH project. + * + * The returned string is an opaque desktop key, not a navigable URL. Encoding + * both components prevents two hosts with the same filesystem path from + * colliding in recents, sessions, or the one-window-per-project map. + */ +export function remoteProjectKey(machineId: string, remotePath: string): string { + return `ssh:${encodeURIComponent(machineId)}:${encodeURIComponent(remotePath)}`; +} + +export function isRemoteProjectKey(value: string): boolean { + if (!value.startsWith('ssh:')) return false; + const separator = value.indexOf(':', 4); + if (separator <= 4 || separator === value.length - 1) return false; + try { + return ( + decodeURIComponent(value.slice(4, separator)).length > 0 && + decodeURIComponent(value.slice(separator + 1)).length > 0 + ); + } catch { + return false; + } +} diff --git a/packages/core/src/sharing/receive-flow.ts b/packages/core/src/sharing/receive-flow.ts index 2230613c9..db89cbf00 100644 --- a/packages/core/src/sharing/receive-flow.ts +++ b/packages/core/src/sharing/receive-flow.ts @@ -47,6 +47,16 @@ export interface RecentProjectEntry { lastOpenedAt: string; missing?: boolean; gitRemoteUrl?: string; + /** + * Present for an SSH-backed recent. `path` remains the opaque, machine-scoped + * project key used by existing project-open APIs; this object carries the + * human-facing remote path and machine label. + */ + remote?: { + machineId: string; + machineName: string; + path: string; + }; /** * Git-worktree relationship, computed at list-time (not persisted) so the * project switcher can nest linked worktrees under their main project. diff --git a/packages/desktop/src/main/boot-restore-decision.test.ts b/packages/desktop/src/main/boot-restore-decision.test.ts index 51bb7fccd..3537d0b47 100644 --- a/packages/desktop/src/main/boot-restore-decision.test.ts +++ b/packages/desktop/src/main/boot-restore-decision.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from 'bun:test'; -import { bootRestoreDecision } from './boot-restore-decision.ts'; +import { bootLaunchNeedsEarlyGit, bootRestoreDecision } from './boot-restore-decision.ts'; function existsIn(paths: string[]): (p: string) => boolean { const set = new Set(paths); @@ -160,3 +160,23 @@ describe('bootRestoreDecision', () => { }); }); }); + +describe('bootLaunchNeedsEarlyGit', () => { + test('does not gate the Navigator or project restores before dispatch', () => { + expect(bootLaunchNeedsEarlyGit({ clearSnapshot: false, action: 'navigator' }, false)).toBe( + false, + ); + expect( + bootLaunchNeedsEarlyGit( + { clearSnapshot: true, action: 'restore', projects: ['ssh:machine:%2Frepo'] }, + false, + ), + ).toBe(false); + }); + + test('gates a cold-start share but not a single-file URL launch', () => { + const urlOwned = { clearSnapshot: false, action: 'none' } as const; + expect(bootLaunchNeedsEarlyGit(urlOwned, false)).toBe(true); + expect(bootLaunchNeedsEarlyGit(urlOwned, true)).toBe(false); + }); +}); diff --git a/packages/desktop/src/main/boot-restore-decision.ts b/packages/desktop/src/main/boot-restore-decision.ts index 3674fa785..6c438889a 100644 --- a/packages/desktop/src/main/boot-restore-decision.ts +++ b/packages/desktop/src/main/boot-restore-decision.ts @@ -22,6 +22,19 @@ export type BootRestoreDecision = | { clearSnapshot: boolean; action: 'navigator' } | { clearSnapshot: boolean; action: 'none' }; +/** + * Whether startup itself must verify local Git before dispatching the chosen + * action. Local project restores are gated later by `openProject`; Navigator + * and SSH-backed restores need no local Git. Only a cold-start share clones + * before that local-project gate, while a single-file URL is Git-free. + */ +export function bootLaunchNeedsEarlyGit( + decision: BootRestoreDecision, + singleFileLaunch: boolean, +): boolean { + return decision.action === 'none' && !singleFileLaunch; +} + // Pure boot-restore decision. A non-null `pendingRestore` means an update // relaunch happened, so the snapshot is always consumed (`clearSnapshot`) even // when Option suppresses the actual restore. A non-null-but-empty/all-missing diff --git a/packages/desktop/src/main/branch-info-proxy.test.ts b/packages/desktop/src/main/branch-info-proxy.test.ts index ac0dc1a31..c602c23d7 100644 --- a/packages/desktop/src/main/branch-info-proxy.test.ts +++ b/packages/desktop/src/main/branch-info-proxy.test.ts @@ -48,6 +48,22 @@ const validBranchInfo: BranchInfoResponse = { }; describe('resolveProjectServerOrigin', () => { + test('uses a direct transport origin without probing a local lock', async () => { + let lockReads = 0; + const origin = await resolveProjectServerOrigin( + 'ssh:machine:project', + buildDeps({ + resolveProjectOrigin: () => 'http://127.0.0.1:54321', + readServerLock: () => { + lockReads += 1; + return null; + }, + }), + ); + expect(origin).toBe('http://127.0.0.1:54321'); + expect(lockReads).toBe(0); + }); + test('returns http origin when the lock is live', async () => { const origin = await resolveProjectServerOrigin('/tmp/p', buildDeps()); expect(origin).toBe('http://localhost:12345'); diff --git a/packages/desktop/src/main/branch-info-proxy.ts b/packages/desktop/src/main/branch-info-proxy.ts index 5bfa943e5..ff6555938 100644 --- a/packages/desktop/src/main/branch-info-proxy.ts +++ b/packages/desktop/src/main/branch-info-proxy.ts @@ -68,6 +68,8 @@ export interface BranchInfoProxyDeps { readonly readServerLock: (lockDir: string) => ServerLockReadShape | null; readonly isProcessAlive: (pid: number) => boolean; readonly fetch: typeof fetch; + /** Direct origin for transports without a local on-disk server lock. */ + readonly resolveProjectOrigin?: (projectPath: string) => string | null | undefined; /** Delay between lock-presence polls. Default 50ms. */ readonly pollIntervalMs?: number; /** Maximum total time spent polling for the lock. Default 5_000ms. */ @@ -95,6 +97,8 @@ export async function resolveProjectServerOrigin( deps: BranchInfoProxyDeps, signal?: AbortSignal, ): Promise { + const directOrigin = deps.resolveProjectOrigin?.(projectPath); + if (directOrigin !== undefined) return directOrigin; const lockDir = joinPath(projectPath, '.ok', 'local'); const pollIntervalMs = deps.pollIntervalMs ?? 50; const pollTimeoutMs = deps.pollTimeoutMs ?? 5_000; diff --git a/packages/desktop/src/main/index.ts b/packages/desktop/src/main/index.ts index 4e1777d11..f68cda2cc 100644 --- a/packages/desktop/src/main/index.ts +++ b/packages/desktop/src/main/index.ts @@ -21,9 +21,10 @@ * 6. bootAutoUpdater (wired last so update toasts find a real window) * 7. macOS Dock icon click → re-open Navigator * - * Process model: one BrowserWindow ↔ one utilityProcess ↔ one Hocuspocus - * server ↔ one contentDir. The window manager owns spawn/teardown; this - * entry wires it into Electron lifecycle + IPC handlers. + * Process model: a local project window owns/attaches to one utility server; + * an SSH-backed window owns a loopback tunnel to the server beside the remote + * project. The window manager owns both lifecycles; this entry wires them into + * Electron lifecycle + IPC handlers. */ import { spawn } from 'node:child_process'; @@ -69,9 +70,14 @@ import { } from '@inkeep/open-knowledge'; import { CLIENT_VERSION_HEADER, + isRemoteProjectKey, PROTOCOL_VERSION, + type RemoteProjectInfo, + remoteProjectKey, ServerInfoSuccessSchema, SPAWN_ERROR_LOG, + type SshMachine, + TERMINAL_CLI_IDS, TERMINAL_CLIS, type TerminalCli, } from '@inkeep/open-knowledge-core'; @@ -102,7 +108,11 @@ import { writeBundleDecision, writeTargetVersion, } from '@inkeep/open-knowledge-server'; -import type { BrowserWindowConstructorOptions, MessageBoxOptions } from 'electron'; +import type { + BrowserWindowConstructorOptions, + IpcMainInvokeEvent, + MessageBoxOptions, +} from 'electron'; import { app, BrowserWindow, @@ -140,7 +150,7 @@ import { channelFromVersion, type StartAutoUpdaterHandle, } from './auto-updater.ts'; -import { resolveBootRestoreDecision } from './boot-restore-decision.ts'; +import { bootLaunchNeedsEarlyGit, resolveBootRestoreDecision } from './boot-restore-decision.ts'; import { runBootstrap } from './bootstrap.ts'; import { type BranchInfoProxyDeps, @@ -239,6 +249,10 @@ import { runMcpWiringOnFirstLaunch, } from './mcp-wiring.ts'; import { installApplicationMenu } from './menu.ts'; +import { + isAuthorizedProjectProxyIpcSender, + isTrustedNavigatorIpcSender, +} from './navigator-trust-boundary.ts'; import { createNavigatorWindow, tryCloseNavigator } from './navigator-window.ts'; import { runOkInit } from './ok-init.ts'; import { @@ -271,6 +285,17 @@ import { type ReducedTransparencyDeps, type VibrancyMaterial, } from './reduced-transparency-handler.ts'; +import { attachRemoteEditorTrustBoundary } from './remote-editor-trust-boundary.ts'; +import { RemoteMachineOpenGuard } from './remote-machine-open-guard.ts'; +import { parseRemoteDispatchRequest, resolveRemoteProjectOpen } from './remote-project-open.ts'; +import { + buildSshTerminalArgs, + createRemoteProjectService, + DEFAULT_SSH_PATH, + validateSshMachine, +} from './remote-project-service.ts'; +import { RemoteReconnectCoordinator } from './remote-reconnect-coordinator.ts'; +import { createRemoteSessionCleanup } from './remote-session-lifecycle.ts'; import { removeGitFolder } from './remove-git-folder.ts'; import { attachRendererConsoleCapture } from './renderer-console-capture.ts'; import { resolveDetachedSpawnArgs } from './resolve-detached-spawn-args.ts'; @@ -287,6 +312,7 @@ import { type RendererMarks, StartupWaterfall } from './startup-waterfall.ts'; import { type AppState, addRecentProject, + addRecentRemoteProject, annotateMissing, emptyState, evaluateSchemaCompatibility, @@ -295,8 +321,10 @@ import { type PersistedWindowBounds, parseAppState, removeRecentProject, + removeSshMachine, type SchemaIncompatibilityDiagnostic, saveAppStateToDir, + saveSshMachine, setLastUsedProjectParent, setProjectSessionState, setProjectWindowBounds, @@ -323,7 +351,14 @@ import { resolveTerminalWindowProject, type TerminalBrowserWindow, } from './terminal-window.ts'; -import { getTerminalWindowContext, resolvePtyProjectRoot } from './terminal-window-registry.ts'; +import { + closeTerminalWindowsForProject, + getTerminalWindowContext, + hasLocalProjectAuthority, + isPtyWindowAuthorityCurrent, + resolvePtyProjectRoot, + resolveWindowProjectAuthority, +} from './terminal-window-registry.ts'; import { applyThemeApplied } from './theme-applied-handler.ts'; import { applyThemeSource, isOkThemeSource } from './theme-handler.ts'; import { @@ -701,6 +736,10 @@ function setSpellCheckEnabledAppWide(enabled: boolean): void { */ function attachSpellcheckMenuToWindow(win: BrowserWindow): void { session.defaultSession.setSpellCheckerEnabled(appState.spellCheckEnabled); + // The application menu is global, but local-file actions are not valid for + // SSH-backed windows. Rebuild on focus so switching between local and remote + // projects cannot leave the previous window's enablement behind. + win.on('focus', refreshApplicationMenu); const openExternalSafely = handleShellOpenExternal({ openExternal: (url) => shell.openExternal(url), }); @@ -725,6 +764,18 @@ function attachSpellcheckMenuToWindow(win: BrowserWindow): void { } let navigatorWindow: BrowserWindowLike | null = null; let wm: WindowManager; +const remoteProjectService = createRemoteProjectService({ + remoteCompanionPath: app.isPackaged + ? join(process.resourcesPath, 'cli', 'dist', 'remote-companion.mjs') + : join(__dirname, '../../../cli/dist/remote-companion.mjs'), +}); +/** Effective OpenSSH endpoint identity pinned when each remote project opens. */ +const remoteConnectionFingerprints = new Map< + string, + { readonly fingerprint: string; readonly owner: symbol } +>(); +const remoteMachineOpenGuard = new RemoteMachineOpenGuard(); +const remoteReconnectCoordinator = new RemoteReconnectCoordinator(); /** * Module-scoped reap surface of the docked-terminal PTY mediator, published by * `registerIpcHandlers` (which runs before any window is created). Lifted out @@ -1302,6 +1353,9 @@ function ensureWindowManager() { removeDir: (dir: string) => fsPromises.rm(dir, { recursive: true, force: true }), rendererEntryPath, rendererDevUrl, + openExternal: handleShellOpenExternal({ + openExternal: (url) => shell.openExternal(url), + }), appVersion: app.getVersion(), // The desktop's own server identity — what its bundled server would write // to a lock. Equal to `app.getVersion()` under the fixed-group lockstep, @@ -1425,6 +1479,9 @@ function openNavigator(pendingPayload?: ShareNavigatorPayload) { ? join(process.resourcesPath, 'app', 'index.html') : join(__dirname, '../renderer/index.html'), rendererDevUrl, + openExternal: handleShellOpenExternal({ + openExternal: (url) => shell.openExternal(url), + }), appVersion: app.getVersion(), showGate, pendingPayload, @@ -1472,6 +1529,38 @@ function logAiIntegrationOutcomes(result: ProjectAiIntegrationsResult): number { // the cap is exceeded, so the probe stays cheap on typical vault-sized trees. const BOOT_BUDGET_FILE_CAP = 10_000; +let localGitPreflightReady = false; +let localGitPreflightInFlight: Promise | null = null; + +/** + * Gate only operations that touch a local project. The Navigator and SSH-backed + * projects do not need Git on the desktop machine, so running this globally at + * startup would prevent a remote-only user from adding their first machine. + */ +async function ensureLocalGitReady(): Promise { + if (localGitPreflightReady) return true; + if (localGitPreflightInFlight) return localGitPreflightInFlight; + + const pending = ensureGitAvailable({ + assertGitAvailable, + // Electron's MessageBoxOptions wants a mutable `buttons: string[]`; the + // handler's contract uses `readonly string[]`. + showMessageBox: async (opts) => dialog.showMessageBox({ ...opts, buttons: [...opts.buttons] }), + openExternal: (url) => shell.openExternal(url), + log: { warn: (msg, obj) => console.warn(msg, obj) }, + }).then((outcome) => { + if (outcome === 'aborted') return false; + localGitPreflightReady = true; + return true; + }); + localGitPreflightInFlight = pending; + try { + return await pending; + } finally { + if (localGitPreflightInFlight === pending) localGitPreflightInFlight = null; + } +} + async function openProject( projectPath: string, entryPoint: EntryPoint, @@ -1490,6 +1579,10 @@ async function openProject( }, 'opening project', ); + if (!(await ensureLocalGitReady())) { + app.quit(); + return; + } ensureWindowManager(); // Admission funnel. Resolve the pick BEFORE any window/utility spawn so we @@ -1874,6 +1967,177 @@ async function openProject( refreshApplicationMenu(); } +function remoteProjectName(remotePath: string): string { + const trimmed = remotePath.replace(/[\\/]+$/, ''); + const name = trimmed.split(/[\\/]/).pop(); + return name && name.length > 0 ? name : remotePath; +} + +function findSshMachine(machineId: string): SshMachine { + const machine = appState.sshMachines.find((candidate) => candidate.id === machineId); + if (!machine) throw new Error('The saved SSH machine no longer exists.'); + return machine; +} + +/** Resolve SSH metadata for either an editor window or a detached terminal. */ +function remoteProjectForWindow(window: BrowserWindow | null): RemoteProjectInfo | undefined { + if (!window) return undefined; + const editor = wm + ? wm.getContextForBrowserWindow(window as unknown as BrowserWindowLike) + : undefined; + return editor?.remote ?? getTerminalWindowContext(window.id)?.remote; +} + +function connectionFingerprintForRemote(remote: RemoteProjectInfo): string | undefined { + return remoteConnectionFingerprints.get(remoteProjectKey(remote.machineId, remote.path)) + ?.fingerprint; +} + +/** + * Open an SSH-backed project through a desktop-owned loopback tunnel. The + * renderer still talks to the normal project HTTP/WebSocket server, so the + * file tree, search, editor, assets, and CRDT persistence stay on the remote + * machine without teaching those surfaces a second transport. + */ +async function openRemoteProject( + machineId: string, + remotePath: string, + initialize: boolean, +): Promise { + return remoteMachineOpenGuard.run(machineId, () => + openRemoteProjectWithStableMachine(machineId, remotePath, initialize), + ); +} + +async function openRemoteProjectWithStableMachine( + machineId: string, + remotePath: string, + initialize: boolean, +): Promise { + const machine = findSshMachine(machineId); + ensureWindowManager(); + + // The Navigator inspects and canonicalizes the path before it reaches this + // function. Recents therefore focus an existing editor before opening SSH. + const requestedKey = remoteProjectKey(machine.id, remotePath); + const requestedExisting = wm.focusWindowForProject(requestedKey); + if (requestedExisting) { + appState = addRecentRemoteProject( + appState, + requestedKey, + remoteProjectName(remotePath), + machine, + remotePath, + ); + saveAppState(appState); + refreshApplicationMenu(); + tryCloseNavigator(navigatorWindow, { projectPath: requestedKey }); + return; + } + + const session = await remoteProjectService.startProject(machine, remotePath, { initialize }); + const projectKey = remoteProjectKey(machine.id, session.projectPath); + const canonicalExisting = wm.focusWindowForProject(projectKey); + if (canonicalExisting) { + session.close(); + appState = addRecentRemoteProject( + appState, + projectKey, + remoteProjectName(session.projectPath), + machine, + session.projectPath, + ); + saveAppState(appState); + refreshApplicationMenu(); + tryCloseNavigator(navigatorWindow, { projectPath: projectKey }); + return; + } + + const remote: RemoteProjectInfo = { + kind: 'ssh', + machineId: machine.id, + machineName: machine.name, + path: session.projectPath, + platform: session.platform, + pathSeparator: session.pathSeparator, + }; + const projectName = remoteProjectName(session.projectPath); + const previousFingerprint = remoteConnectionFingerprints.get(projectKey); + const connectionOwner = Symbol(projectKey); + remoteConnectionFingerprints.set(projectKey, { + fingerprint: session.connectionFingerprint, + owner: connectionOwner, + }); + const sessionCleanup = createRemoteSessionCleanup({ + closeAttachedWindows: () => closeTerminalWindowsForProject(projectKey), + closeTransport: session.close, + isAuthoritative: () => remoteConnectionFingerprints.get(projectKey)?.owner === connectionOwner, + releaseAuthority: () => { + if (remoteConnectionFingerprints.get(projectKey)?.owner === connectionOwner) { + remoteConnectionFingerprints.delete(projectKey); + } + }, + }); + let ctx: NonNullable>; + try { + ctx = await wm.createRemoteProjectWindow({ + projectKey, + projectName, + remote, + port: session.localPort, + apiOrigin: session.apiOrigin, + collabUrl: session.collabUrl, + retireTunnel: session.closeTunnel, + closeSession: sessionCleanup.close, + }); + } catch (error) { + // WindowManager normally invokes this on every failed create path. Keep an + // explicit idempotent close here so a future constructor failure cannot + // strand the SSH server before a ProjectContext exists. + sessionCleanup.close(); + if (previousFingerprint === undefined) remoteConnectionFingerprints.delete(projectKey); + else remoteConnectionFingerprints.set(projectKey, previousFingerprint); + throw error; + } + const openedNewSession = ctx.apiOrigin === session.apiOrigin; + if (!openedNewSession) { + if (previousFingerprint === undefined) remoteConnectionFingerprints.delete(projectKey); + else remoteConnectionFingerprints.set(projectKey, previousFingerprint); + } else { + // From this point the window owns the fresh transport. Its teardown may + // revoke attached remote terminal windows before releasing the tunnel. + sessionCleanup.commit(); + } + + getLogger('project').info( + { + projectName, + machineId: machine.id, + apiOrigin: session.apiOrigin, + ownedRemoteServer: session.owned, + }, + 'remote project window created', + ); + appState = addRecentRemoteProject( + appState, + projectKey, + projectName, + machine, + session.projectPath, + ); + saveAppState(appState); + refreshApplicationMenu(); + tryCloseNavigator(navigatorWindow, { projectPath: projectKey }); +} + +async function openRecentRemoteProject(projectKey: string): Promise { + const recent = appState.recentProjects.find((entry) => entry.path === projectKey); + if (!recent?.remote) { + throw new Error('The remote project metadata is missing. Remove this recent and add it again.'); + } + await openRemoteProject(recent.remote.machineId, recent.remote.path, false); +} + async function openProjectOrFallbackToNavigator( projectPath: string, entryPoint: EntryPoint, @@ -1883,6 +2147,24 @@ async function openProjectOrFallbackToNavigator( pendingShareBranchSwitch?: ShareDeepLinkBranchSwitchPayload, pendingTargetMissing?: boolean, ) { + if (isRemoteProjectKey(projectPath)) { + try { + await openRecentRemoteProject(projectPath); + } catch (err) { + const recent = appState.recentProjects.find((entry) => entry.path === projectPath); + const detail = recent?.remote + ? `${recent.remote.machineName} • ${recent.remote.path}` + : projectPath; + const errorMessage = err instanceof Error ? err.message : String(err); + getLogger('project').warn( + { projectName: recent?.name, error: errorMessage }, + 'remote project open failed', + ); + dialog.showErrorBox('Unable to open remote project', `${detail}\n\n${errorMessage}`); + openNavigator(); + } + return; + } try { await openProject( projectPath, @@ -2106,6 +2388,7 @@ function refreshApplicationMenu(): void { } async function runApplicationMenuRefresh(): Promise { + const focusedRemote = remoteProjectForWindow(BrowserWindow.getFocusedWindow()) !== undefined; // installApplicationMenu is async because it dynamically imports // `electron.Menu` (see menu.ts header — keeps `buildMenuTemplate` // unit-testable under Bun). Failures are logged; an uninstallable menu @@ -2232,13 +2515,16 @@ async function runApplicationMenuRefresh(): Promise { // Worktree selector (worktree = window). Both delegate to the // focused renderer's ProjectSwitcher surface: `new-worktree` opens the // create dialog, `switch-worktree` opens the sidebar switcher. - onNewWorktree: () => sendMenuActionToFocused('new-worktree'), - onSwitchWorktree: () => sendMenuActionToFocused('switch-worktree'), + onNewWorktree: focusedRemote ? undefined : () => sendMenuActionToFocused('new-worktree'), + onSwitchWorktree: focusedRemote ? undefined : () => sendMenuActionToFocused('switch-worktree'), onRename: () => sendMenuActionToFocused('rename'), onDuplicate: () => sendMenuActionToFocused('duplicate'), - onMoveToTrash: () => sendMenuActionToFocused('move-to-trash'), + // Remote deletion is a permanent server operation, never macOS Trash. + // Keep the misleading native menu item disabled; the in-app delete flow + // presents explicit permanent-delete confirmation copy. + onMoveToTrash: focusedRemote ? undefined : () => sendMenuActionToFocused('move-to-trash'), onCloseActiveTabOrWindow: () => sendMenuActionToFocused('close-active-tab-or-window'), - onRevealInFinder: () => sendMenuActionToFocused('reveal-in-finder'), + onRevealInFinder: focusedRemote ? undefined : () => sendMenuActionToFocused('reveal-in-finder'), onSendToAi: () => sendMenuActionToFocused('send-to-ai'), onCopyFullPath: () => sendMenuActionToFocused('copy-full-path'), onCopyRelativePath: () => sendMenuActionToFocused('copy-relative-path'), @@ -2502,8 +2788,10 @@ async function startDesktopSelfUninstallFlow(): Promise { } const projectCandidates = collectDesktopUninstallProjectCandidates({ - recentProjects: appState.recentProjects, - openProjectPaths: wm?.getOpenProjectPaths() ?? [], + recentProjects: appState.recentProjects.filter((recent) => recent.remote === undefined), + openProjectPaths: (wm?.getOpenProjectPaths() ?? []).filter( + (projectPath) => !isRemoteProjectKey(projectPath), + ), lockDirs, }); let projectPaths: string[] = []; @@ -2614,6 +2902,24 @@ function openTerminalWindow(): void { }); applyCascadePosition(win); attachSpellcheckMenuToWindow(win); + if (project?.remote) { + attachRemoteEditorTrustBoundary( + win.webContents, + { rendererEntryPath, rendererDevUrl }, + { + apiOrigin: project.apiOrigin, + openExternal: handleShellOpenExternal({ + openExternal: (url) => shell.openExternal(url), + }), + log: (event) => { + getLogger('security').warn( + { event: 'desktop-remote-terminal-navigation-blocked', ...event.data }, + event.message, + ); + }, + }, + ); + } return win as unknown as TerminalBrowserWindow; }, rendererEntryPath, @@ -2948,6 +3254,77 @@ function resolveTerminalCliOnPath(cli: TerminalCli): Promise { }); } +/** + * Resolve a CLI against the remote login-interactive PATH used by SSH-backed + * terminal sessions. Remote project MCP configuration is intentionally never + * auto-approved here: Desktop cannot safely inspect the remote agent's config, + * so Claude/Codex retain their own trust prompts. + */ +async function resolveRemoteTerminalCliOnPath( + machine: SshMachine, + cli: TerminalCli, + expectedConnectionFingerprint?: string, +): Promise { + try { + const available = await remoteProjectService.isCommandAvailable( + machine, + TERMINAL_CLIS[cli].bin, + expectedConnectionFingerprint, + ); + return { + onPath: available ? 'present' : 'not-found', + ...(cli === 'codex' ? { okServerConfigured: false } : {}), + }; + } catch (err) { + getLogger('terminal').warn( + { machineId: machine.id, cli, err: formatUnknownError(err) }, + 'remote CLI PATH probe failed', + ); + return { + onPath: 'unknown', + ...(cli === 'codex' ? { okServerConfigured: false } : {}), + }; + } +} + +async function resolveRemoteTerminalClaudeReadiness( + machine: SshMachine, + expectedConnectionFingerprint?: string, +): Promise { + const cli = await resolveRemoteTerminalCliOnPath( + machine, + 'claude', + expectedConnectionFingerprint, + ); + return { + claude: cli.onPath, + // Desktop cannot inspect or repair Claude's configuration on the SSH + // machine. Keep the state honest and avoid offering the local rewire flow. + mcp: 'unknown', + mcpPreApprovable: false, + }; +} + +async function resolveRemoteTerminalCliInstalledMap( + machine: SshMachine, + expectedConnectionFingerprint?: string, +): Promise> { + const entries = await Promise.all( + TERMINAL_CLI_IDS.map( + async (cli) => + [ + cli, + await remoteProjectService.isCommandAvailable( + machine, + TERMINAL_CLIS[cli].bin, + expectedConnectionFingerprint, + ), + ] as const, + ), + ); + return Object.fromEntries(entries) as Record; +} + /** * Time-to-live for the cached batched CLI installed-map. The New-chat default * auto-pick re-queries on each click; installs/uninstalls are rare, so a short @@ -3133,6 +3510,8 @@ function registerIpcHandlers() { const win = BrowserWindow.fromWebContents(event.sender); const editorCtx = win && wm ? wm.getContextForBrowserWindow(win as unknown as BrowserWindowLike) : null; + const terminalWindow = win ? getTerminalWindowContext(win.id) : undefined; + const remote = editorCtx?.remote ?? terminalWindow?.remote; // A standalone terminal window is not in `windowsByPath` (one-per-project, // focus-existing), so `getContextForBrowserWindow` returns nothing for it. // Editor windows keep their existing per-project resolution; a terminal @@ -3140,12 +3519,12 @@ function registerIpcHandlers() { // falling back to homedir() when project-less (never null — create() refuses // null). A window in neither map (e.g. the Navigator) resolves to null and // is refused below rather than spawning a shell at an arbitrary dir. - const projectPath = resolvePtyProjectRoot({ + const resolvedProjectPath = resolvePtyProjectRoot({ editorProjectPath: editorCtx?.projectPath ?? null, - terminalWindow: win ? getTerminalWindowContext(win.id) : undefined, + terminalWindow, homedir: osHomedir(), }); - if (!win || !projectPath) { + if (!win || !resolvedProjectPath) { logIpcError({ event: 'ipc.error', channel: 'ok:pty:create', @@ -3166,7 +3545,11 @@ function registerIpcHandlers() { // race — re-enabling (false→absent) — so a shell-open immediately after // re-enable isn't refused on a stale `false`; never trusting the renderer. // The not-opted-out path stays instant and only a just-re-enabled open waits. - if (!isTerminalConsented(projectPath) && !(await isTerminalConsentedWithGrace(projectPath))) { + if ( + remote === undefined && + !isTerminalConsented(resolvedProjectPath) && + !(await isTerminalConsentedWithGrace(resolvedProjectPath)) + ) { logIpcError({ event: 'ipc.error', channel: 'ok:pty:create', @@ -3175,13 +3558,89 @@ function registerIpcHandlers() { }); return { ok: false, reason: 'not-consented' }; } + let remoteMachine: SshMachine | null = null; + if (remote) { + try { + remoteMachine = findSshMachine(remote.machineId); + } catch (err) { + getLogger('terminal').warn( + { machineId: remote.machineId, err: formatUnknownError(err) }, + 'remote terminal machine is no longer available', + ); + return { ok: false, reason: 'remote-unavailable' }; + } + } + if (remote && remoteMachine) { + let allowed: boolean; + try { + allowed = await remoteProjectService.isTerminalAllowed( + remoteMachine, + remote.path, + connectionFingerprintForRemote(remote), + ); + } catch (err) { + getLogger('terminal').warn( + { machineId: remote.machineId, err: formatUnknownError(err) }, + 'remote terminal consent check failed; refusing shell', + ); + return { ok: false, reason: 'remote-unavailable' }; + } + if (!allowed) { + logIpcError({ + event: 'ipc.error', + channel: 'ok:pty:create', + reason: 'not-consented', + handler: 'createPty', + }); + return { ok: false, reason: 'not-consented' }; + } + } + // Both consent paths may await. The window can close (or an editor can be + // replaced) while the probe is in flight, after its close reaper has + // already run. Revalidate the exact captured authority before creating a + // PTY so a completed probe cannot resurrect an orphan SSH/local shell. + const liveWindow = BrowserWindow.fromWebContents(event.sender); + const liveEditorContext = + liveWindow && wm + ? wm.getContextForBrowserWindow(liveWindow as unknown as BrowserWindowLike) + : null; + const liveTerminalWindow = liveWindow ? getTerminalWindowContext(liveWindow.id) : undefined; + if ( + !isPtyWindowAuthorityCurrent({ + sameWindow: liveWindow === win, + windowDestroyed: win.isDestroyed(), + senderDestroyed: event.sender.isDestroyed(), + capturedEditorContext: editorCtx, + liveEditorContext, + capturedTerminalWindow: terminalWindow, + liveTerminalWindow, + }) + ) { + logIpcError({ + event: 'ipc.error', + channel: 'ok:pty:create', + reason: 'no-project', + handler: 'createPty', + }); + return { ok: false, reason: 'no-project' }; + } return terminalManager.create({ windowId: win.id, webContents: win.webContents, - projectRoot: projectPath, + // node-pty's cwd is local even when the child executable is ssh. The + // remote command performs its own argument-safe `cd` before starting the + // login shell. + projectRoot: remote ? osHomedir() : resolvedProjectPath, cols: clampPtyDimension(opts.cols, DEFAULT_PTY_COLS), rows: clampPtyDimension(opts.rows, DEFAULT_PTY_ROWS), - launchCommand: opts.launchCommand, + ...(remote && remoteMachine + ? { + executable: { + file: DEFAULT_SSH_PATH, + args: buildSshTerminalArgs(remoteMachine, remote.path, opts.launchCommand), + }, + } + : { launchCommand: opts.launchCommand }), }); }); handle('ok:pty:input', async (event, req) => { @@ -3250,54 +3709,109 @@ function registerIpcHandlers() { return undefined; }); handle('ok:terminal:claude-assist', async (event, req) => { + const callerWin = BrowserWindow.fromWebContents(event.sender); + const callerRemote = remoteProjectForWindow(callerWin); let rewireError: string | undefined; - if (req.action === 'rewire' && process.platform === 'darwin' && app.isPackaged) { + if ( + !callerRemote && + req.action === 'rewire' && + process.platform === 'darwin' && + app.isPackaged + ) { // Re-arm MCP wiring: the same forceShow consent path as // File -> Set up OpenKnowledge integrations, so the user can wire // `open-knowledge` into Claude Code. Fires ONLY from the renderer's // re-wire button — agents have no ok:terminal:* surface, and the consent // dialog itself is human-only. - const win = BrowserWindow.fromWebContents(event.sender); mcpWiringHandle?.destroy(); mcpWiringHandle = null; try { mcpWiringHandle = armMcpWiring({ forceShow: true, - immediateDispatchTarget: win?.webContents, + immediateDispatchTarget: callerWin?.webContents, }); } catch (err) { rewireError = formatUnknownError(err); getLogger('terminal').warn({ err: rewireError }, 'claude mcp rewire failed'); } } + if (callerRemote) { + let machine: SshMachine; + try { + machine = findSshMachine(callerRemote.machineId); + } catch (err) { + getLogger('terminal').warn( + { machineId: callerRemote.machineId, err: formatUnknownError(err) }, + 'remote Claude preflight machine is no longer available', + ); + return { + claude: 'unknown', + mcp: 'unknown', + mcpPreApprovable: false, + } satisfies ClaudeReadiness; + } + return resolveRemoteTerminalClaudeReadiness( + machine, + connectionFingerprintForRemote(callerRemote), + ); + } // Scope the project-MCP pre-approval check to the caller window's project // (its `.mcp.json` is what `claude` reads in the PTY cwd). A window with no // bound project → undefined → not pre-approvable (Claude prompts). - const callerWin = BrowserWindow.fromWebContents(event.sender); - const projectRoot = + const callerContext = callerWin && wm - ? wm.getContextForBrowserWindow(callerWin as unknown as BrowserWindowLike)?.projectPath + ? wm.getContextForBrowserWindow(callerWin as unknown as BrowserWindowLike) : undefined; + const terminalContext = callerWin ? getTerminalWindowContext(callerWin.id) : undefined; + const projectRoot = callerContext?.projectPath ?? terminalContext?.projectRoot ?? undefined; const readiness = await resolveTerminalClaudeReadiness(projectRoot); // Surface the rewire failure to the renderer so the button doesn't no-op // silently; readiness itself is still computed for the rest of the banner. return rewireError === undefined ? readiness : { ...readiness, rewireError }; }); - handle('ok:terminal:cli-preflight', async (_event, req): Promise => { + handle('ok:terminal:cli-preflight', async (event, req): Promise => { // `req.cli` crosses the IPC boundary as a compile-time `TerminalCli`, but // `createHandler` casts rawArgs without runtime enforcement — validate the // untrusted discriminant against the registry before it indexes // `TERMINAL_CLIS[...].bin`. An out-of-registry value yields a safe `unknown` // verdict (never a silent TypeError, never a `command -v ` probe). - if (!(req.cli in TERMINAL_CLIS)) { + if (typeof req.cli !== 'string' || !Object.hasOwn(TERMINAL_CLIS, req.cli)) { getLogger('terminal').warn({ cli: req.cli }, 'cli-preflight: unknown cli discriminant'); return { onPath: 'unknown' }; } + const remote = remoteProjectForWindow(BrowserWindow.fromWebContents(event.sender)); + if (remote) { + let machine: SshMachine; + try { + machine = findSshMachine(remote.machineId); + } catch (err) { + getLogger('terminal').warn( + { machineId: remote.machineId, cli: req.cli, err: formatUnknownError(err) }, + 'remote CLI preflight machine is no longer available', + ); + return { + onPath: 'unknown', + ...(req.cli === 'codex' ? { okServerConfigured: false } : {}), + }; + } + return resolveRemoteTerminalCliOnPath( + machine, + req.cli, + connectionFingerprintForRemote(remote), + ); + } return resolveTerminalCliOnPath(req.cli); }); - handle('ok:terminal:cli-installed-map', async (): Promise> => { + handle('ok:terminal:cli-installed-map', async (event): Promise> => { + const remote = remoteProjectForWindow(BrowserWindow.fromWebContents(event.sender)); + if (remote) { + return resolveRemoteTerminalCliInstalledMap( + findSshMachine(remote.machineId), + connectionFingerprintForRemote(remote), + ); + } return resolveTerminalCliInstalledMap(); }); @@ -3329,18 +3843,23 @@ function registerIpcHandlers() { }); handle('ok:shell:spawn-cursor', async (event, path) => { - // Scope the spawn to the caller window's project directory. A - // BrowserWindow without a ProjectContext (e.g. the Navigator, before it - // spawns an editor) should never reach this handler, but we treat that - // case as "no project scope" — a missing `projectPath` passes through to - // `spawnCursorImpl` which gates on the presence of the field. The - // validateSpawnPath + isPathWithinProject checks inside the impl refuse - // any out-of-scope path when a project IS bound. + // Scope the spawn to the caller-owned editor or standalone-terminal + // project. Remote paths belong to the SSH machine and must never be handed + // to a local Cursor process. A genuinely project-less caller preserves the + // existing unscoped behavior; every project-bound local caller is + // containment-gated by `spawnCursorImpl`. const callerWin = BrowserWindow.fromWebContents(event.sender); - const callerProjectPath = + const callerContext = callerWin && wm - ? wm.getContextForBrowserWindow(callerWin as unknown as BrowserWindowLike)?.projectPath + ? wm.getContextForBrowserWindow(callerWin as unknown as BrowserWindowLike) : undefined; + const callerAuthority = resolveWindowProjectAuthority({ + editorProjectPath: callerContext?.projectPath, + editorRemote: callerContext?.remote, + terminalWindow: callerWin ? getTerminalWindowContext(callerWin.id) : undefined, + }); + if (callerAuthority?.remote) return { ok: false, reason: 'invalid-path' } as const; + const callerProjectPath = callerAuthority?.projectPath; const outcome = await spawnCursorImpl( { platform: process.platform, @@ -3401,11 +3920,12 @@ function registerIpcHandlers() { // asset scope. handle('ok:shell:open-asset', async (event, relPath) => { const callerWin = BrowserWindow.fromWebContents(event.sender); - const callerProjectPath = + const callerContext = callerWin && wm - ? wm.getContextForBrowserWindow(callerWin as unknown as BrowserWindowLike)?.projectPath + ? wm.getContextForBrowserWindow(callerWin as unknown as BrowserWindowLike) : undefined; - if (!callerProjectPath) { + const callerProjectPath = callerContext?.projectPath; + if (!callerProjectPath || callerContext?.remote) { logIpcError({ event: 'ipc.error', channel: 'ok:shell:open-asset', @@ -3435,11 +3955,12 @@ function registerIpcHandlers() { handle('ok:shell:reveal-asset', async (event, relPath) => { const callerWin = BrowserWindow.fromWebContents(event.sender); - const callerProjectPath = + const callerContext = callerWin && wm - ? wm.getContextForBrowserWindow(callerWin as unknown as BrowserWindowLike)?.projectPath + ? wm.getContextForBrowserWindow(callerWin as unknown as BrowserWindowLike) : undefined; - if (!callerProjectPath) { + const callerProjectPath = callerContext?.projectPath; + if (!callerProjectPath || callerContext?.remote) { logIpcError({ event: 'ipc.error', channel: 'ok:shell:reveal-asset', @@ -3478,10 +3999,9 @@ function registerIpcHandlers() { handle('ok:shell:show-asset-menu', async (event, params) => { const callerWin = BrowserWindow.fromWebContents(event.sender); if (!callerWin || !wm) return undefined; - const projectPath = wm.getContextForBrowserWindow( - callerWin as unknown as BrowserWindowLike, - )?.projectPath; - if (!projectPath) return undefined; + const callerContext = wm.getContextForBrowserWindow(callerWin as unknown as BrowserWindowLike); + const projectPath = callerContext?.projectPath; + if (!projectPath || callerContext?.remote) return undefined; popAssetMenu( { Menu, @@ -3524,10 +4044,11 @@ function registerIpcHandlers() { // Resolve caller window's project directory (undefined for Navigator). // Validation, refusal, and security rationale live in `showItemInFolderImpl`. const callerWin = BrowserWindow.fromWebContents(event.sender); - const callerProjectPath = + const callerContext = callerWin && wm - ? wm.getContextForBrowserWindow(callerWin as unknown as BrowserWindowLike)?.projectPath + ? wm.getContextForBrowserWindow(callerWin as unknown as BrowserWindowLike) : undefined; + const callerProjectPath = callerContext?.remote ? undefined : callerContext?.projectPath; const result = showItemInFolderImpl( { platform: process.platform, @@ -3550,6 +4071,14 @@ function registerIpcHandlers() { // Out-of-project reveal for terminal clickable-links. Uncontained by design; // the confirmation dialog is the trust boundary (see reveal-external.ts). const callerWin = BrowserWindow.fromWebContents(event.sender); + const callerContext = + callerWin && wm + ? wm.getContextForBrowserWindow(callerWin as unknown as BrowserWindowLike) + : undefined; + const terminalContext = callerWin ? getTerminalWindowContext(callerWin.id) : undefined; + if (callerContext?.remote || terminalContext?.remote) { + return { ok: false, reason: 'invalid-path' } as const; + } const result = await handleRevealExternal(absPath, { // statSync (not existsSync) so a permission error (EACCES/EPERM on a system // path) surfaces as `unreadable` rather than being flattened to `missing`. @@ -3585,10 +4114,14 @@ function registerIpcHandlers() { handle('ok:shell:trash-item', async (event, absPath) => { const callerWin = BrowserWindow.fromWebContents(event.sender); - const callerProjectPath = + const callerContext = callerWin && wm - ? wm.getContextForBrowserWindow(callerWin as unknown as BrowserWindowLike)?.projectPath + ? wm.getContextForBrowserWindow(callerWin as unknown as BrowserWindowLike) : undefined; + if (callerContext?.remote) { + return { ok: false, reason: 'path-escape' } as const; + } + const callerProjectPath = callerContext?.projectPath; // Path normalization happens at span-creation time using the renderer // input (pre-realpath). The post-realpath canonical path is what we'd // emit to logs/index — but it may include the user home prefix, so we @@ -3670,6 +4203,97 @@ function registerIpcHandlers() { return undefined; }); + handle('ok:remote:dispatch', async (event, request) => { + const callerWindow = BrowserWindow.fromWebContents(event.sender); + if ( + callerWindow === null || + !isTrustedNavigatorIpcSender(callerWindow, navigatorWindow, event.senderFrame, { + rendererEntryPath: app.isPackaged + ? join(process.resourcesPath, 'app', 'index.html') + : join(__dirname, '../renderer/index.html'), + rendererDevUrl, + }) + ) { + throw new Error('ok:remote:dispatch rejected: Navigator window required'); + } + const typedRequest = parseRemoteDispatchRequest(request); + if (typedRequest === null) { + throw new Error('ok:remote:dispatch rejected: invalid request'); + } + const machineFor = (machineId: unknown): SshMachine => { + if (typeof machineId !== 'string' || machineId.length === 0) { + throw new Error('ok:remote:dispatch rejected: invalid machine id'); + } + return findSshMachine(machineId); + }; + + switch (typedRequest.kind) { + case 'list-machines': + return appState.sshMachines; + case 'save-machine': { + if (typeof typedRequest.machine !== 'object' || typedRequest.machine === null) { + throw new Error('ok:remote:dispatch rejected: invalid machine'); + } + const requestedId = typedRequest.machine.id; + if (typeof requestedId === 'string') remoteMachineOpenGuard.assertMutable(requestedId); + if ( + requestedId !== undefined && + !appState.sshMachines.some((machine) => machine.id === requestedId) + ) { + throw new Error('ok:remote:dispatch rejected: unknown machine id'); + } + // Validate the original object plus the main-owned id as one exact + // allowlisted shape. Extra renderer fields are rejected by + // validateSshMachine instead of being silently dropped. + const machine = validateSshMachine({ + ...typedRequest.machine, + id: requestedId ?? randomUUID(), + }); + appState = saveSshMachine(appState, machine); + saveAppState(appState); + refreshApplicationMenu(); + return machine; + } + case 'remove-machine': { + const machine = machineFor(typedRequest.machineId); + remoteMachineOpenGuard.assertMutable(machine.id); + const activePrefix = `ssh:${encodeURIComponent(machine.id)}:`; + if ([...remoteConnectionFingerprints.keys()].some((key) => key.startsWith(activePrefix))) { + throw new Error("Close this machine's open projects before removing it."); + } + appState = removeSshMachine(appState, machine.id); + saveAppState(appState); + refreshApplicationMenu(); + return undefined; + } + case 'test-machine': + return remoteProjectService.testMachine(machineFor(typedRequest.machineId)); + case 'list-directories': { + if (typeof typedRequest.path !== 'string') { + throw new Error('ok:remote:dispatch rejected: invalid remote path'); + } + return remoteProjectService.listDirectories( + machineFor(typedRequest.machineId), + typedRequest.path, + ); + } + case 'open-project': { + return remoteMachineOpenGuard.run(typedRequest.machineId, async () => { + const machine = machineFor(typedRequest.machineId); + const inspection = await remoteProjectService.inspectProject(machine, typedRequest.path); + const decision = await resolveRemoteProjectOpen(machine, inspection, { + showInitializationDialog: (options) => dialog.showMessageBox(callerWindow, options), + }); + if (decision === null) return false; + await openRemoteProjectWithStableMachine(machine.id, decision.path, decision.initialize); + return true; + }); + } + default: + throw new Error('ok:remote:dispatch rejected: unsupported operation'); + } + }); + handle('ok:theme:set-source', async (_event, { source }) => { return applyThemeSource( { @@ -3729,10 +4353,11 @@ function registerIpcHandlers() { const ctx = wm?.getContextForBrowserWindow(win as unknown as BrowserWindowLike); if (!ctx) throw new Error('No project context for this window'); return { - collabUrl: `ws://localhost:${ctx.port}/collab`, + collabUrl: `ws://${ctx.remote ? '127.0.0.1' : 'localhost'}:${ctx.port}/collab`, apiOrigin: ctx.apiOrigin, projectPath: ctx.projectPath, projectName: ctx.projectName, + remote: ctx.remote ?? null, mode: 'editor' as const, // Mirrors the preload's cold-start config: `true` under the Electron // smoke suite so the renderer uses xterm's DOM renderer (see TerminalPanel). @@ -3760,6 +4385,16 @@ function registerIpcHandlers() { if (!win) throw new Error('webContents has no parent BrowserWindow'); const ctx = wm?.getContextForBrowserWindow(win as unknown as BrowserWindowLike); if (!ctx) throw new Error('No project context for this window'); + if (ctx.remote) { + return request.kind === 'status' + ? { + kind: 'status' as const, + mode: 'no-git' as const, + excluded: [], + trackedUpstream: [], + } + : { kind: 'no-exclude' as const, reason: 'no-git' as const }; + } if (request.kind === 'status') { return handleSharingStatus(ctx.projectPath); } @@ -3779,7 +4414,7 @@ function registerIpcHandlers() { // paths are left un-probed. return Promise.all( annotateMissing(appState).map(async (entry): Promise => { - if (entry.missing) return entry; + if (entry.missing || entry.remote !== undefined) return entry; const [git, branch] = await Promise.all([ classifyRecentGitAsync(entry.path), // Resolve the branch via git (walks up), not a raw `.git/HEAD` read, so @@ -3851,6 +4486,10 @@ function registerIpcHandlers() { `ok:project:open rejected: invalid entryPoint '${String(request.entryPoint)}'`, ); } + if (isRemoteProjectKey(request.path)) { + await openProjectOrFallbackToNavigator(request.path, request.entryPoint); + return undefined; + } // Renderer-initiated share-receive opens (fresh clone, multi-worktree pivot) // reach window-open here instead of through the URL-scheme dispatcher, which // is where `dispatchResolvedShare` probes the target. Run the same probe so a @@ -3928,7 +4567,7 @@ function registerIpcHandlers() { const ctx = win && wm ? wm.getContextForBrowserWindow(win as unknown as BrowserWindowLike) : null; const projectPath = ctx?.projectPath ?? null; - if (!projectPath) { + if (!projectPath || ctx?.remote) { logIpcError({ event: 'ipc.error', channel: 'ok:worktree:dispatch', @@ -3964,10 +4603,14 @@ function registerIpcHandlers() { }); handle('ok:project:check-target-exists', async (_event, request) => { + if (isRemoteProjectKey(request.projectPath)) return 'unreadable' as const; return checkTargetExistsImpl(request.projectPath, request.kind, request.path); }); handle('ok:project:read-head-branch', async (_event, projectPath) => { + if (isRemoteProjectKey(projectPath)) { + return { currentBranch: null, headSha: null, detached: false }; + } return readHeadBranchImpl(projectPath); }); @@ -3975,28 +4618,90 @@ function registerIpcHandlers() { readServerLock: (lockDir) => readServerLock(lockDir), isProcessAlive, fetch: globalThis.fetch, + resolveProjectOrigin: (projectPath) => { + const ctx = wm?.getWindowFor(projectPath); + return ctx?.remote ? ctx.apiOrigin : undefined; + }, log: { warn: (message, meta) => console.warn(message, meta ?? {}), }, }; - handle('ok:project:fetch-branch-info', async (_event, request) => { + const projectProxyRendererTarget = { + rendererEntryPath: app.isPackaged + ? join(process.resourcesPath, 'app', 'index.html') + : join(__dirname, '../renderer/index.html'), + rendererDevUrl, + }; + const authorizeProjectProxyRequest = ( + event: IpcMainInvokeEvent, + requestedProjectPath: unknown, + channel: string, + ): boolean => { + const callerWindow = BrowserWindow.fromWebContents(event.sender); + const callerContext = + callerWindow && wm + ? wm.getContextForBrowserWindow(callerWindow as unknown as BrowserWindowLike) + : undefined; + const authorized = isAuthorizedProjectProxyIpcSender({ + callerWindow, + navigatorWindow, + frame: event.senderFrame, + target: projectProxyRendererTarget, + callerProjectPath: callerContext?.projectPath, + requestedProjectPath, + }); + if (!authorized) { + logIpcError({ + event: 'ipc.error', + channel, + reason: 'project-mismatch', + handler: 'authorizeProjectProxyRequest', + }); + } + return authorized; + }; + + handle('ok:project:fetch-branch-info', async (event, request) => { + if (!authorizeProjectProxyRequest(event, request.projectPath, 'ok:project:fetch-branch-info')) { + return null; + } return proxyFetchBranchInfo(request, branchInfoProxyDeps); }); - handle('ok:project:run-checkout', async (_event, request) => { + handle('ok:project:run-checkout', async (event, request) => { + if (!authorizeProjectProxyRequest(event, request.projectPath, 'ok:project:run-checkout')) { + return null; + } return proxyRunCheckout(request, branchInfoProxyDeps); }); - handle('ok:project:fetch-target-status', async (_event, request) => { + handle('ok:project:fetch-target-status', async (event, request) => { + if ( + !authorizeProjectProxyRequest(event, request.projectPath, 'ok:project:fetch-target-status') + ) { + return null; + } return proxyShareTargetStatus(request, branchInfoProxyDeps); }); - handle('ok:project:await-branch-switched', async (_event, request) => { + handle('ok:project:await-branch-switched', async (event, request) => { + if ( + !authorizeProjectProxyRequest(event, request.projectPath, 'ok:project:await-branch-switched') + ) { + return { ok: false as const, reason: 'project-not-open' as const }; + } return proxyAwaitBranchSwitched(request, branchInfoProxyDeps); }); handle('ok:project:ok-init', async (_event, request) => { + if (isRemoteProjectKey(request.projectPath)) { + return { + ok: false as const, + reason: 'init-failed' as const, + message: 'Remote worktree initialization is not available from this window.', + }; + } return runOkInit(request.projectPath); }); @@ -4010,24 +4715,240 @@ function registerIpcHandlers() { return undefined; }); - handle('ok:project:restart-server', async (_event, projectPath) => { - // Renderer-initiated from the version-drift notification. Terminates the - // attached (not-owned) server and recreates the window against a fresh - // own-version spawn. The returned outcome only reaches the renderer on - // failure (a surviving window) — success recreates the originating window. - // The try/catch makes the contract uniform: every path resolves with an - // outcome rather than rejecting on a destroyed renderer. - if (!wm) { + handle('ok:project:restart-server', async (event, projectPath) => { + // Renderer-initiated after a stopped server or version-drift notification. + // Local projects spawn a replacement utility process; SSH projects first + // establish a fresh remote session and transactionally replace the window. + // The returned outcome only reaches the renderer on failure (a surviving + // window) — success closes and recreates the originating window. + const windowManager = wm; + const callerWindow = BrowserWindow.fromWebContents(event.sender); + const callerContext = + windowManager && callerWindow + ? windowManager.getContextForBrowserWindow(callerWindow as unknown as BrowserWindowLike) + : undefined; + const callerTerminalContext = callerWindow + ? getTerminalWindowContext(callerWindow.id) + : undefined; + const callerAuthority = resolveWindowProjectAuthority({ + editorProjectPath: callerContext?.projectPath, + editorRemote: callerContext?.remote, + terminalWindow: callerTerminalContext, + }); + + if (callerContext?.remote) { + // Never trust the renderer to select a different project or machine for + // this lifecycle operation. The BrowserWindow-owned context is the + // authoritative identity. + if (projectPath !== callerContext.projectPath) { + logIpcError({ + event: 'ipc.error', + channel: 'ok:project:restart-server', + reason: 'project-mismatch', + handler: 'restartServer', + }); + return { ok: false, reason: 'other' }; + } + + const priorRemote = callerContext.remote; + return remoteReconnectCoordinator.run(callerContext.projectPath, () => + remoteMachineOpenGuard.run(priorRemote.machineId, async () => { + let reconnectOwner: symbol | undefined; + let reconnectFingerprint: string | undefined; + let releaseReconnectAuthority: (() => void) | undefined; + let priorTransportRevoked = false; + try { + const machine = findSshMachine(priorRemote.machineId); + const priorConnection = remoteConnectionFingerprints.get(callerContext.projectPath); + if (priorConnection === undefined) { + throw new Error('The remote project connection identity is missing.'); + } + if ( + callerContext.window.isDestroyed?.() === true || + windowManager.getContextForBrowserWindow(callerContext.window) !== callerContext + ) { + throw new Error('The remote project window closed while reconnecting.'); + } + // A remote companion exclusively owns its server. Revoke the old + // transport before starting the replacement; the editor window + // stays visible so a failed restart can report and retry cleanly. + const closePriorSession = callerContext.closeRemoteSession; + callerContext.closeRemoteSession = undefined; + callerContext.retireRemoteTunnel = undefined; + closePriorSession?.(); + priorTransportRevoked = true; + reconnectOwner = Symbol(callerContext.projectPath); + reconnectFingerprint = priorConnection.fingerprint; + const owner = reconnectOwner; + releaseReconnectAuthority = () => { + if (remoteConnectionFingerprints.get(callerContext.projectPath)?.owner === owner) { + remoteConnectionFingerprints.delete(callerContext.projectPath); + } + }; + remoteConnectionFingerprints.set(callerContext.projectPath, { + fingerprint: priorConnection.fingerprint, + owner, + }); + // Until a fresh session exists, the surviving editor window owns + // the temporary reconnect authority. Closing it while SSH waits + // must not leave a retry fingerprint pinned without a window. + callerContext.closeRemoteSession = releaseReconnectAuthority; + const session = await remoteProjectService.startProject(machine, priorRemote.path, { + initialize: false, + expectedConnectionFingerprint: priorConnection.fingerprint, + waitForOwnerExit: true, + }); + if ( + callerContext.window.isDestroyed?.() === true || + windowManager.getContextForBrowserWindow(callerContext.window) !== callerContext + ) { + session.close(); + throw new Error('The remote project window closed while reconnecting.'); + } + const projectKey = remoteProjectKey(machine.id, session.projectPath); + if (projectKey !== callerContext.projectPath) { + // A canonical-path identity change cannot be committed as an in-place + // restart without corrupting the one-window-per-project map. + session.close(); + throw new Error('The remote project canonical path changed while reconnecting.'); + } + + const remote: RemoteProjectInfo = { + kind: 'ssh', + machineId: machine.id, + machineName: machine.name, + path: session.projectPath, + platform: session.platform, + pathSeparator: session.pathSeparator, + }; + const projectName = remoteProjectName(session.projectPath); + const connectionOwner = Symbol(projectKey); + const sessionCleanup = createRemoteSessionCleanup({ + closeAttachedWindows: () => closeTerminalWindowsForProject(projectKey), + closeTransport: session.close, + isAuthoritative: () => + remoteConnectionFingerprints.get(projectKey)?.owner === connectionOwner, + releaseAuthority: () => { + if (remoteConnectionFingerprints.get(projectKey)?.owner === connectionOwner) { + remoteConnectionFingerprints.delete(projectKey); + } + }, + }); + const closeReplacementSession = () => { + try { + sessionCleanup.close(); + } finally { + releaseReconnectAuthority?.(); + } + }; + // From here the replacement session owns temporary cleanup. The + // originating context must be unowned before WindowManager starts + // its exclusive replacement transaction. + callerContext.closeRemoteSession = undefined; + try { + const replacementContext = await windowManager.replaceRemoteProjectWindow( + { + projectKey, + projectName, + remote, + port: session.localPort, + apiOrigin: session.apiOrigin, + collabUrl: session.collabUrl, + retireTunnel: session.closeTunnel, + closeSession: closeReplacementSession, + }, + callerContext.window, + ); + if (replacementContext.apiOrigin !== session.apiOrigin) { + throw new Error('A different remote session won the reconnect transaction.'); + } + if ( + replacementContext.window.isDestroyed?.() === true || + windowManager.getContextForBrowserWindow(replacementContext.window) !== + replacementContext + ) { + throw new Error('The replacement remote project window closed before handoff.'); + } + // Existing standalone terminals were launched with the originating + // tunnel URLs. Revoke and close them at the transaction boundary; + // keeping them until final editor teardown would leave dead attach + // contexts after a reconnect chose a new local port. + closeTerminalWindowsForProject(projectKey); + // Switch authority only after WindowManager commits. During + // load, the temporary reconnect owner pins the same endpoint. + remoteConnectionFingerprints.set(projectKey, { + fingerprint: session.connectionFingerprint, + owner: connectionOwner, + }); + sessionCleanup.commit(); + } catch (error) { + closeReplacementSession(); + closeTerminalWindowsForProject(projectKey); + throw error; + } + + appState = addRecentRemoteProject( + appState, + projectKey, + projectName, + machine, + session.projectPath, + ); + saveAppState(appState); + refreshApplicationMenu(); + return { ok: true }; + } catch (err) { + if (priorTransportRevoked) { + const originStillOpen = + callerContext.window.isDestroyed?.() !== true && + windowManager.getContextForBrowserWindow(callerContext.window) === callerContext; + if ( + originStillOpen && + reconnectOwner !== undefined && + reconnectFingerprint !== undefined && + releaseReconnectAuthority + ) { + remoteConnectionFingerprints.set(callerContext.projectPath, { + fingerprint: reconnectFingerprint, + owner: reconnectOwner, + }); + callerContext.closeRemoteSession = releaseReconnectAuthority; + callerContext.retireRemoteTunnel = undefined; + } else { + releaseReconnectAuthority?.(); + } + } + logIpcError({ + event: 'ipc.error', + channel: 'ok:project:restart-server', + reason: 'remote-reconnect-failed', + handler: 'restartServer', + cause: err, + }); + return { ok: false, reason: 'other' }; + } + }), + ); + } + + // A remote-shaped key without a matching sender-owned remote context is a + // rejected trust-boundary crossing, not a local filesystem request. + if ( + !windowManager || + !hasLocalProjectAuthority(callerAuthority, projectPath) || + isRemoteProjectKey(projectPath) || + windowManager.getWindowFor(projectPath)?.remote + ) { logIpcError({ event: 'ipc.error', channel: 'ok:project:restart-server', - reason: 'no-window-manager', + reason: 'project-mismatch', handler: 'restartServer', }); return { ok: false, reason: 'other' }; } try { - const outcome = await wm.restartAttachedServer(projectPath, { + const outcome = await windowManager.restartAttachedServer(projectPath, { localOpCliArgs: resolveLocalOpCliArgs(), }); if (outcome.ok === false) { @@ -4256,9 +5177,11 @@ function registerIpcHandlers() { // See packages/desktop/src/main/ipc/seed.ts. const resolveSeedProjectRoot = (event: Electron.IpcMainInvokeEvent): string | undefined => { const callerWin = BrowserWindow.fromWebContents(event.sender); - return callerWin && wm - ? wm.getContextForBrowserWindow(callerWin as unknown as BrowserWindowLike)?.projectPath - : undefined; + const ctx = + callerWin && wm + ? wm.getContextForBrowserWindow(callerWin as unknown as BrowserWindowLike) + : undefined; + return ctx?.remote ? undefined : ctx?.projectPath; }; handle('ok:seed:plan', async (event, options) => { const result = await handleSeedPlan( @@ -4624,9 +5547,8 @@ function registerProjectIntegrationsSettingsIpc(): void { resolveProjectDir: (event) => { const win = BrowserWindow.fromWebContents(event.sender); if (!win) return null; - return ( - wm.getContextForBrowserWindow(win as unknown as BrowserWindowLike)?.projectPath ?? null - ); + const ctx = wm.getContextForBrowserWindow(win as unknown as BrowserWindowLike); + return ctx?.remote ? null : (ctx?.projectPath ?? null); }, tildify: tildifyHomePath, logger: { @@ -5127,7 +6049,7 @@ function bootPrimaryInstance(): void { pendingRestore: appState.pendingWindowRestore, lastOpenedProject: appState.lastOpenedProject, optionHeld: process.argv.includes('--navigator'), - pathExists: existsSync, + pathExists: (projectPath) => isRemoteProjectKey(projectPath) || existsSync(projectPath), // A launch-claiming URL that opens its own window — a single-file open // (`ok `) OR a valid share — suppresses the default boot-restore // window so the URL flush owns the launch. Read AFTER the settle barrier @@ -5156,38 +6078,17 @@ function bootPrimaryInstance(): void { } } - // Git preflight — runs for every launch EXCEPT a single-file deep-link, - // whose ephemeral server boots git-off. Projects use git for the shadow - // repo, so a missing/old binary surfaces here as a recoverable native - // dialog (Open Install Page / Retry / Quit) instead of a spawn-ENOENT deep - // in a later CRDT trace, BEFORE the project window + detached server child - // are created. The Navigator preflights too — it opens no git-backed server - // itself, but it's the gateway to project opens, so the gate stays where it - // was pre-fix. Only the no-project ephemeral single-file shape skips it: - // that server boots git-off, so requiring git would block `ok ` for a - // user without it. A share launch ALSO yields `action: 'none'` (it - // suppresses the default window) but opens/clones a git-backed project, so - // it still preflights — gate on `singleFileLaunch()`, not the bare `'none'`. - // A project later opened from a single-file session falls back to the - // server child's own bootServer() preflight as the backstop. - const skipGitPreflight = decision.action === 'none' && protocolControl.singleFileLaunch(); - if (!skipGitPreflight) { - const gitOutcome = await ensureGitAvailable({ - assertGitAvailable, - // Electron's MessageBoxOptions wants a mutable `buttons: string[]`; the - // handler's contract uses `readonly string[]`. Spread to a fresh - // mutable copy at the boundary. - showMessageBox: async (opts) => - dialog.showMessageBox({ ...opts, buttons: [...opts.buttons] }), - openExternal: (url) => shell.openExternal(url), - log: { warn: (msg, obj) => console.warn(msg, obj) }, - }); - if (gitOutcome === 'aborted') { - // User clicked Quit (or an unrecoverable non-typed error fired). Open - // no window; bootstrap ran but no project window/server was spawned. - app.quit(); - return; - } + // Local project opens run the recoverable Git preflight inside + // `openProject`, while remote opens and the Navigator require no local + // Git at all. A cold-start share is the one startup flow that performs a + // Git clone before `openProject` gets control, so retain the early gate + // for that shape. Single-file URL launches remain Git-free. + if ( + bootLaunchNeedsEarlyGit(decision, protocolControl.singleFileLaunch()) && + !(await ensureLocalGitReady()) + ) { + app.quit(); + return; } if (decision.action === 'restore') { diff --git a/packages/desktop/src/main/menu.ts b/packages/desktop/src/main/menu.ts index 0468f6caa..6ded83735 100644 --- a/packages/desktop/src/main/menu.ts +++ b/packages/desktop/src/main/menu.ts @@ -60,7 +60,11 @@ export interface MenuDeps { */ openProject(projectPath: string, entryPoint: EntryPoint): Promise; /** Current recent-projects list (top-of-LRU first). Used to build Recent project submenu. */ - getRecentProjects(): ReadonlyArray<{ path: string; name: string }>; + getRecentProjects(): ReadonlyArray<{ + path: string; + name: string; + remote?: { machineName: string; path: string }; + }>; /** Clear the recent-projects list (File → Recent project → Clear menu). */ clearRecentProjects(): void; /** Open an external URL (Help menu). Injected so the `shell` runtime value doesn't cross the module boundary. */ @@ -330,7 +334,7 @@ export function buildMenuTemplate(deps: MenuDeps): MenuItemConstructorOptions[] : [ ...recents.slice(0, 10).map((row) => ({ label: row.name, - sublabel: row.path, + sublabel: row.remote ? `${row.remote.machineName} • ${row.remote.path}` : row.path, click: () => { void deps.openProject(row.path, 'recents'); }, diff --git a/packages/desktop/src/main/navigator-trust-boundary.ts b/packages/desktop/src/main/navigator-trust-boundary.ts new file mode 100644 index 000000000..9b6491908 --- /dev/null +++ b/packages/desktop/src/main/navigator-trust-boundary.ts @@ -0,0 +1,210 @@ +/** + * Trust boundary for the project Navigator. + * + * The Navigator has no project context, but its preload exposes privileged + * launcher operations (including the SSH-machine dispatcher). Keep its main + * frame pinned to the renderer entry point and refuse child-window creation. + * This module is Electron-free so the URL and frame checks stay easy to test. + */ + +import { resolve } from 'node:path'; +import { pathToFileURL } from 'node:url'; +import { checkOutboundUrl } from './shell-allowlist.ts'; + +export interface NavigatorRendererTarget { + /** Path passed to BrowserWindow.loadFile in packaged/production builds. */ + readonly rendererEntryPath: string; + /** URL passed to BrowserWindow.loadURL while the Vite dev server is active. */ + readonly rendererDevUrl?: string | null; +} + +interface NavigatorFrameLike { + readonly url?: unknown; + readonly parent?: unknown; +} + +interface NavigatorWebContentsLike { + setWindowOpenHandler(handler: (details: { url: string }) => { action: 'deny' }): void; + on( + event: 'will-navigate', + handler: (event: { preventDefault(): void }, url: string) => void, + ): void; +} + +interface NavigatorTrustBoundaryDeps { + /** + * Delegate an allowlisted outbound URL to the OS. Production passes the + * shared `handleShellOpenExternal` handler; the local scheme check below is + * an additional guard against an accidentally-unsafe future injection. + */ + readonly openExternal: (url: string) => Promise; + readonly log?: (event: { + readonly message: string; + readonly data: Readonly>; + }) => void; +} + +const DEFAULT_LOG: Required['log'] = (event) => { + console.warn(`[navigator-trust-boundary] ${event.message}`, event.data); +}; + +/** + * Return true only for a URL owned by the configured Navigator renderer. + * + * Dev uses the Vite origin so HMR/full reloads and Vite-owned module paths + * keep working. Requiring the same `http(s)` protocol rejects blob/data URLs + * even when their serialized origin embeds the trusted dev origin. + * + * Packaged builds are narrower: only the exact file loaded via `loadFile` is + * trusted (query/hash are renderer-local state and therefore ignored). + */ +export function isTrustedNavigatorUrl(url: unknown, target: NavigatorRendererTarget): boolean { + if (typeof url !== 'string' || url.length === 0) return false; + + let candidate: URL; + try { + candidate = new URL(url); + } catch { + return false; + } + + if (target.rendererDevUrl) { + let expected: URL; + try { + expected = new URL(target.rendererDevUrl); + } catch { + return false; + } + if (expected.protocol !== 'http:' && expected.protocol !== 'https:') return false; + return candidate.protocol === expected.protocol && candidate.origin === expected.origin; + } + + const expected = pathToFileURL(resolve(target.rendererEntryPath)); + if (candidate.protocol !== 'file:') return false; + candidate.hash = ''; + candidate.search = ''; + return candidate.href === expected.href; +} + +/** + * IPC privilege is granted only to the Navigator's trusted top-level frame. + * Checking `event.sender`/BrowserWindow alone is insufficient: an iframe in + * that WebContents has the same sender and can invoke preload-exposed IPC. + */ +export function isTrustedNavigatorSenderFrame( + frame: NavigatorFrameLike | null | undefined, + target: NavigatorRendererTarget, +): boolean { + if (!frame || typeof frame !== 'object') return false; + try { + // Electron documents `parent === null` as the top-frame test. Avoid + // comparing `frame.top === frame`: distinct WebFrameMain wrappers can + // represent the same underlying frame, making object identity unreliable. + if (frame.parent !== null) return false; + return isTrustedNavigatorUrl(frame.url, target); + } catch { + // A disposed WebFrameMain can throw from accessors during navigation. + return false; + } +} + +/** + * Combined main-process authorization gate for Navigator-only IPC handlers. + * Window identity prevents an editor/terminal caller; the frame check prevents + * a child frame or navigated-away Navigator from borrowing that identity. + */ +export function isTrustedNavigatorIpcSender( + callerWindow: unknown, + navigatorWindow: unknown, + frame: NavigatorFrameLike | null | undefined, + target: NavigatorRendererTarget, +): boolean { + return ( + callerWindow !== null && + callerWindow !== undefined && + callerWindow === navigatorWindow && + isTrustedNavigatorSenderFrame(frame, target) + ); +} + +/** + * Authorize an IPC request that proxies one project's server on behalf of a + * renderer. The Navigator may target a project selected by the user during a + * share flow; every other window is restricted to the project bound to its + * own WindowManager context. Requiring the trusted top-level renderer frame + * prevents a child frame from borrowing either window's authority. + */ +export function isAuthorizedProjectProxyIpcSender(args: { + readonly callerWindow: unknown; + readonly navigatorWindow: unknown; + readonly frame: NavigatorFrameLike | null | undefined; + readonly target: NavigatorRendererTarget; + readonly callerProjectPath: string | null | undefined; + readonly requestedProjectPath: unknown; +}): boolean { + if ( + args.callerWindow === null || + args.callerWindow === undefined || + typeof args.requestedProjectPath !== 'string' || + args.requestedProjectPath.length === 0 || + !isTrustedNavigatorSenderFrame(args.frame, args.target) + ) { + return false; + } + + if (args.callerWindow === args.navigatorWindow) return true; + return args.callerProjectPath === args.requestedProjectPath; +} + +function delegateExternal( + url: string, + source: 'new-window' | 'navigation', + deps: NavigatorTrustBoundaryDeps, +): void { + const log = deps.log ?? DEFAULT_LOG; + const check = checkOutboundUrl(url); + if (!check.ok) { + log({ + message: 'blocked outbound URL', + data: { source, url, reason: check.reason }, + }); + return; + } + + void deps.openExternal(url).catch((err: unknown) => { + log({ + message: 'openExternal failed', + data: { + source, + url, + err: err instanceof Error ? err.message : String(err), + }, + }); + }); +} + +/** + * Pin a Navigator WebContents to its configured renderer target. + * + * - Child-window requests are always denied. External, allowlisted URLs are + * opened by the OS instead of becoming privileged child renderers. + * - Top-level navigation stays in-process only for the trusted app target. + * Cross-origin URLs are prevented and safely delegated to the OS. + */ +export function attachNavigatorTrustBoundary( + webContents: NavigatorWebContentsLike, + target: NavigatorRendererTarget, + deps: NavigatorTrustBoundaryDeps, +): void { + webContents.setWindowOpenHandler(({ url }) => { + // A trusted app URL must not create a second privileged Navigator window. + if (!isTrustedNavigatorUrl(url, target)) delegateExternal(url, 'new-window', deps); + return { action: 'deny' }; + }); + + webContents.on('will-navigate', (event, url) => { + if (isTrustedNavigatorUrl(url, target)) return; + event.preventDefault(); + delegateExternal(url, 'navigation', deps); + }); +} diff --git a/packages/desktop/src/main/navigator-window.ts b/packages/desktop/src/main/navigator-window.ts index 525cd490c..21c2c7ff2 100644 --- a/packages/desktop/src/main/navigator-window.ts +++ b/packages/desktop/src/main/navigator-window.ts @@ -17,6 +17,7 @@ */ import { registerPendingDelivery } from '../shared/ipc-send.ts'; +import { attachNavigatorTrustBoundary } from './navigator-trust-boundary.ts'; import type { ShowGateRegistry } from './show-gate.ts'; import type { ShareNavigatorPayload } from './url-scheme.ts'; import type { BrowserWindowLike, WindowManagerDeps } from './window-manager.ts'; @@ -53,6 +54,8 @@ interface NavigatorDeps { /** Dev-server URL injected by electron-vite (`process.env.ELECTRON_RENDERER_URL`). * When set, main uses `loadURL` for HMR; otherwise falls back to `loadFile`. */ rendererDevUrl?: string | null; + /** Allowlisted OS-browser delegation for external Navigator links. */ + openExternal: (url: string) => Promise; /** App version, passed to the preload via additionalArguments. */ appVersion: string; /** @@ -89,6 +92,18 @@ export function createNavigatorWindow(deps: NavigatorDeps): BrowserWindowLike { // here. Editor windows override with their own `projectName` title. title: 'OpenKnowledge', }); + // Install the navigation boundary before any renderer load begins. The + // Navigator preload exposes launcher/SSH operations, so a transient + // cross-origin navigation must never retain those privileges and an iframe + // must never gain a child BrowserWindow with the same preload. + attachNavigatorTrustBoundary( + window.webContents, + { + rendererEntryPath: deps.rendererEntryPath, + rendererDevUrl: deps.rendererDevUrl, + }, + { openExternal: deps.openExternal }, + ); // Defer OS-level window display until both first-paint AND chrome-theme // signals arrive — same dual-signal gate as editor windows. The Navigator // path has no utility-process gate, so without this defer the user sees diff --git a/packages/desktop/src/main/remote-editor-trust-boundary.ts b/packages/desktop/src/main/remote-editor-trust-boundary.ts new file mode 100644 index 000000000..0d220dc8a --- /dev/null +++ b/packages/desktop/src/main/remote-editor-trust-boundary.ts @@ -0,0 +1,243 @@ +/** + * Navigation trust boundary for SSH-backed editor windows. + * + * A remote project's API is intentionally reachable through a desktop-owned + * loopback tunnel, but it is not a trusted renderer. In particular, the + * remote server can serve project-authored HTML. If that HTML replaces the + * editor's top-level document it inherits the BrowserWindow preload bridge, + * turning remote project content into a privileged IPC caller. + * + * Keep the WebContents pinned to the local app renderer and deny every child + * window. This module is Electron-free so the URL policy can be tested without + * constructing a BrowserWindow. + */ + +import { resolve } from 'node:path'; +import { pathToFileURL } from 'node:url'; + +export interface RemoteEditorRendererTarget { + /** Path passed to BrowserWindow.loadFile in packaged/production builds. */ + readonly rendererEntryPath: string; + /** URL passed to BrowserWindow.loadURL while the Vite dev server is active. */ + readonly rendererDevUrl?: string | null; +} + +interface RemoteEditorWebContentsLike { + setWindowOpenHandler(handler: (details: { url: string }) => { action: 'deny' }): void; + on( + event: 'will-navigate', + handler: (event: { preventDefault(): void }, url: string) => void, + ): void; + executeJavaScript(code: string): Promise; +} + +interface RemoteEditorTrustBoundaryDeps { + /** The tunneled project server origin. It must never become top-level. */ + readonly apiOrigin: string; + /** Delegate a narrowly allowed public web/mail URL to the OS. */ + readonly openExternal: (url: string) => Promise; + readonly log?: (event: { + readonly message: string; + readonly data: Readonly>; + }) => void; +} + +const EXTERNAL_SCHEMES = new Set(['https:', 'http:', 'mailto:']); + +const DEFAULT_LOG: Required['log'] = (event) => { + console.warn(`[remote-editor-trust-boundary] ${event.message}`, event.data); +}; + +/** + * Return true only for a URL owned by the configured local app renderer. + * + * Dev trusts the configured Vite HTTP(S) origin so HMR and module loads work. + * Packaged builds trust only the exact file loaded with loadFile; comparing + * file origins is unsafe because file/data/blob URLs all serialize to `null`. + */ +export function isTrustedRemoteEditorUrl( + url: unknown, + target: RemoteEditorRendererTarget, +): boolean { + if (typeof url !== 'string' || url.length === 0) return false; + + let candidate: URL; + try { + candidate = new URL(url); + } catch { + return false; + } + + if (target.rendererDevUrl) { + let expected: URL; + try { + expected = new URL(target.rendererDevUrl); + } catch { + return false; + } + if (expected.protocol !== 'http:' && expected.protocol !== 'https:') return false; + return candidate.protocol === expected.protocol && candidate.origin === expected.origin; + } + + const expected = pathToFileURL(resolve(target.rendererEntryPath)); + if (candidate.protocol !== 'file:') return false; + candidate.hash = ''; + candidate.search = ''; + return candidate.href === expected.href; +} + +function normalizeHostname(hostname: string): string { + const lower = hostname.toLowerCase(); + const unbracketed = lower.startsWith('[') && lower.endsWith(']') ? lower.slice(1, -1) : lower; + // A terminal dot only marks an absolute DNS name; `localhost.` still + // resolves to the same loopback host as `localhost`. + return unbracketed.endsWith('.') ? unbracketed.slice(0, -1) : unbracketed; +} + +function isIpv4MappedLocalHostname(hostname: string): boolean { + // URL parsing canonicalizes IPv4-mapped IPv6 literals to + // `::ffff::` before this helper sees them. + const match = /^::ffff:([0-9a-f]{1,4}):([0-9a-f]{1,4})$/.exec(hostname); + const highWord = match?.[1]; + const lowWord = match?.[2]; + if (highWord === undefined || lowWord === undefined) return false; + + const high = Number.parseInt(highWord, 16); + const low = Number.parseInt(lowWord, 16); + return (high & 0xff00) === 0x7f00 || (high === 0 && low === 0); +} + +function isLoopbackHostname(hostname: string): boolean { + const normalized = normalizeHostname(hostname); + if (normalized === 'localhost' || normalized.endsWith('.localhost')) return true; + if (normalized === '::1' || normalized === '0.0.0.0' || normalized === '::') return true; + return /^127(?:\.\d{1,3}){3}$/.test(normalized) || isIpv4MappedLocalHostname(normalized); +} + +function parseUrl(value: unknown): URL | null { + if (typeof value !== 'string' || value.length === 0) return null; + try { + return new URL(value); + } catch { + return null; + } +} + +/** + * Remote project API URLs are blocked rather than delegated to the OS. Treat + * loopback aliases on the tunnel port as equivalent to the announced origin. + */ +export function isRemoteProjectApiUrl(url: unknown, apiOrigin: string): boolean { + const candidate = parseUrl(url); + const expected = parseUrl(apiOrigin); + if (!candidate || !expected) return false; + if (candidate.origin === expected.origin) return true; + return ( + candidate.protocol === expected.protocol && + candidate.port === expected.port && + isLoopbackHostname(candidate.hostname) && + isLoopbackHostname(expected.hostname) + ); +} + +/** + * Only ordinary public web/mail destinations are safe to delegate from an + * untrusted-project boundary. Custom schemes can launch privileged local apps, + * and loopback URLs can target the SSH tunnel or unrelated local services. + */ +export function isSafeRemoteExternalUrl(url: unknown): url is string { + const parsed = parseUrl(url); + if (!parsed || !EXTERNAL_SCHEMES.has(parsed.protocol)) return false; + if (parsed.protocol !== 'mailto:' && isLoopbackHostname(parsed.hostname)) return false; + return true; +} + +function navigateToHashScript(hash: string): string { + return `window.location.hash = ${JSON.stringify(hash)};`; +} + +function trustedInAppHash(url: string, target: RemoteEditorRendererTarget): string | null { + if (!isTrustedRemoteEditorUrl(url, target)) return null; + const parsed = parseUrl(url); + return parsed?.hash.startsWith('#/') ? parsed.hash : null; +} + +function delegateExternal( + url: string, + source: 'new-window' | 'navigation', + deps: RemoteEditorTrustBoundaryDeps, +): void { + const log = deps.log ?? DEFAULT_LOG; + if (!isSafeRemoteExternalUrl(url)) { + log({ message: 'blocked outbound URL', data: { source, url } }); + return; + } + + void deps.openExternal(url).catch((err: unknown) => { + log({ + message: 'openExternal failed', + data: { + source, + url, + err: err instanceof Error ? err.message : String(err), + }, + }); + }); +} + +/** + * Pin an SSH-backed editor WebContents to the local renderer. + * + * This must be installed immediately after BrowserWindow construction and + * before loadURL/loadFile. The remote API origin is always denied, including + * project HTML and loopback aliases. External public web/mail links may leave + * through the OS; custom, local, opaque, and malformed URLs are dropped. + */ +export function attachRemoteEditorTrustBoundary( + webContents: RemoteEditorWebContentsLike, + target: RemoteEditorRendererTarget, + deps: RemoteEditorTrustBoundaryDeps, +): void { + if (!parseUrl(deps.apiOrigin)) { + throw new Error('Cannot attach remote editor trust boundary: invalid API origin.'); + } + const log = deps.log ?? DEFAULT_LOG; + + webContents.setWindowOpenHandler(({ url }) => { + const inAppHash = trustedInAppHash(url, target); + if (inAppHash !== null) { + void webContents.executeJavaScript(navigateToHashScript(inAppHash)).catch((err: unknown) => { + log({ + message: 'in-app navigation failed', + data: { + hash: inAppHash, + err: err instanceof Error ? err.message : String(err), + }, + }); + }); + return { action: 'deny' }; + } + + if (isRemoteProjectApiUrl(url, deps.apiOrigin)) { + log({ message: 'blocked remote API child window', data: { url } }); + return { action: 'deny' }; + } + // Even a trusted renderer URL must not create a second privileged window. + if (!isTrustedRemoteEditorUrl(url, target)) delegateExternal(url, 'new-window', deps); + return { action: 'deny' }; + }); + + webContents.on('will-navigate', (event, url) => { + // Check the API origin first so a configuration collision fails closed. + if (!isRemoteProjectApiUrl(url, deps.apiOrigin) && isTrustedRemoteEditorUrl(url, target)) { + return; + } + + event.preventDefault(); + if (isRemoteProjectApiUrl(url, deps.apiOrigin)) { + log({ message: 'blocked remote API top-level navigation', data: { url } }); + return; + } + delegateExternal(url, 'navigation', deps); + }); +} diff --git a/packages/desktop/src/main/remote-machine-open-guard.test.ts b/packages/desktop/src/main/remote-machine-open-guard.test.ts new file mode 100644 index 000000000..0b4b8653c --- /dev/null +++ b/packages/desktop/src/main/remote-machine-open-guard.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, test } from 'bun:test'; +import { RemoteMachineOpenGuard } from './remote-machine-open-guard.ts'; + +function deferred(): { promise: Promise; resolve: (value: T) => void } { + let resolve!: (value: T) => void; + const promise = new Promise((done) => { + resolve = done; + }); + return { promise, resolve }; +} + +describe('RemoteMachineOpenGuard', () => { + test('blocks mutation until every concurrent open for the machine settles', async () => { + const guard = new RemoteMachineOpenGuard(); + const first = deferred(); + const second = deferred(); + const firstOpen = guard.run('machine-a', () => first.promise); + const secondOpen = guard.run('machine-a', () => second.promise); + + expect(() => guard.assertMutable('machine-a')).toThrow( + 'Wait for this SSH machine to finish opening before changing it.', + ); + expect(() => guard.assertMutable('machine-b')).not.toThrow(); + + first.resolve(); + await firstOpen; + expect(() => guard.assertMutable('machine-a')).toThrow( + 'Wait for this SSH machine to finish opening before changing it.', + ); + + second.resolve(); + await secondOpen; + expect(() => guard.assertMutable('machine-a')).not.toThrow(); + }); + + test('releases the machine when an open fails', async () => { + const guard = new RemoteMachineOpenGuard(); + + await expect( + guard.run('machine-a', async () => { + throw new Error('offline'); + }), + ).rejects.toThrow('offline'); + + expect(() => guard.assertMutable('machine-a')).not.toThrow(); + }); +}); diff --git a/packages/desktop/src/main/remote-machine-open-guard.ts b/packages/desktop/src/main/remote-machine-open-guard.ts new file mode 100644 index 000000000..45e1f7c1b --- /dev/null +++ b/packages/desktop/src/main/remote-machine-open-guard.ts @@ -0,0 +1,23 @@ +const MUTATION_BLOCKED_MESSAGE = 'Wait for this SSH machine to finish opening before changing it.'; + +/** Prevent saved-machine mutation while an open still owns a captured snapshot. */ +export class RemoteMachineOpenGuard { + private readonly inFlight = new Map(); + + async run(machineId: string, open: () => Promise): Promise { + this.inFlight.set(machineId, (this.inFlight.get(machineId) ?? 0) + 1); + try { + return await open(); + } finally { + const remaining = (this.inFlight.get(machineId) ?? 1) - 1; + if (remaining === 0) this.inFlight.delete(machineId); + else this.inFlight.set(machineId, remaining); + } + } + + assertMutable(machineId: string): void { + if ((this.inFlight.get(machineId) ?? 0) > 0) { + throw new Error(MUTATION_BLOCKED_MESSAGE); + } + } +} diff --git a/packages/desktop/src/main/remote-project-open.test.ts b/packages/desktop/src/main/remote-project-open.test.ts new file mode 100644 index 000000000..6c592e2e3 --- /dev/null +++ b/packages/desktop/src/main/remote-project-open.test.ts @@ -0,0 +1,153 @@ +import { describe, expect, mock, test } from 'bun:test'; +import type { SshMachine } from '@inkeep/open-knowledge-core'; +import { parseRemoteDispatchRequest, resolveRemoteProjectOpen } from './remote-project-open.ts'; + +const machine: SshMachine = { + id: 'machine-1', + name: 'Build box', + host: 'build-box', +}; + +describe('resolveRemoteProjectOpen', () => { + test('opens the inspected project root without prompting when already initialized', async () => { + const inspection = { + selectedPath: '/srv/project/docs', + projectPath: '/srv/project', + initialized: true, + }; + const showInitializationDialog = mock(async () => ({ response: 0 })); + + await expect( + resolveRemoteProjectOpen(machine, inspection, { + showInitializationDialog, + }), + ).resolves.toEqual({ path: '/srv/project', initialize: false }); + + expect(showInitializationDialog).not.toHaveBeenCalled(); + }); + + test('cancels initialization by default and describes every remote write', async () => { + const inspection = { + selectedPath: '/home/dev/wiki', + projectPath: '/home/dev/wiki', + initialized: false, + }; + const showInitializationDialog = mock(async () => ({ response: 0 })); + + await expect( + resolveRemoteProjectOpen(machine, inspection, { showInitializationDialog }), + ).resolves.toBeNull(); + + const options = showInitializationDialog.mock.calls[0]?.[0]; + expect(options).toMatchObject({ + type: 'warning', + title: 'Initialize remote project?', + message: 'Initialize OpenKnowledge on Build box?', + buttons: ['Cancel', 'Initialize and open'], + defaultId: 0, + cancelId: 0, + noLink: true, + }); + expect(options?.detail).toContain('Remote path: /home/dev/wiki'); + expect(options?.detail).toContain('.ok/config.yml'); + expect(options?.detail).toContain('.ok/.gitignore'); + expect(options?.detail).toContain('.okignore'); + expect(options?.detail).toContain('.ok/config.yml and .okignore files are never overwritten'); + expect(options?.detail).toContain( + 'existing .ok/.gitignore may receive missing runtime ignore entries', + ); + }); + + test('initializes only the canonical inspected path after explicit confirmation', async () => { + const inspection = { + selectedPath: '/home/dev/wiki', + projectPath: '/home/dev/wiki', + initialized: false, + }; + + await expect( + resolveRemoteProjectOpen(machine, inspection, { + showInitializationDialog: async () => ({ response: 1 }), + }), + ).resolves.toEqual({ path: '/home/dev/wiki', initialize: true }); + }); + + test('rejects an unexpected dialog response instead of silently treating it as consent', async () => { + await expect( + resolveRemoteProjectOpen( + machine, + { + selectedPath: '/srv/wiki', + projectPath: '/srv/wiki', + initialized: false, + }, + { + showInitializationDialog: async () => ({ response: 99 }), + }, + ), + ).rejects.toThrow('Remote project initialization dialog returned an invalid response.'); + }); +}); + +describe('parseRemoteDispatchRequest', () => { + test('accepts every exact remote request shape', () => { + expect(parseRemoteDispatchRequest({ kind: 'list-machines' })).toEqual({ + kind: 'list-machines', + }); + expect( + parseRemoteDispatchRequest({ + kind: 'save-machine', + machine: { id: 'machine-1', name: 'Build box', host: 'build-box', port: 2222 }, + }), + ).toEqual({ + kind: 'save-machine', + machine: { id: 'machine-1', name: 'Build box', host: 'build-box', port: 2222 }, + }); + expect(parseRemoteDispatchRequest({ kind: 'remove-machine', machineId: 'machine-1' })).toEqual({ + kind: 'remove-machine', + machineId: 'machine-1', + }); + expect(parseRemoteDispatchRequest({ kind: 'test-machine', machineId: 'machine-1' })).toEqual({ + kind: 'test-machine', + machineId: 'machine-1', + }); + expect( + parseRemoteDispatchRequest({ + kind: 'list-directories', + machineId: 'machine-1', + path: '/srv', + }), + ).toEqual({ kind: 'list-directories', machineId: 'machine-1', path: '/srv' }); + expect( + parseRemoteDispatchRequest({ + kind: 'open-project', + machineId: 'machine-1', + path: '/srv/wiki', + }), + ).toEqual({ kind: 'open-project', machineId: 'machine-1', path: '/srv/wiki' }); + }); + + test('rejects extra fields and malformed values instead of silently dropping them', () => { + expect( + parseRemoteDispatchRequest({ + kind: 'open-project', + machineId: 'machine-1', + path: '/srv/wiki', + initialize: true, + }), + ).toBeNull(); + expect( + parseRemoteDispatchRequest({ kind: 'open-project', machineId: '', path: '/srv/wiki' }), + ).toBeNull(); + expect( + parseRemoteDispatchRequest({ kind: 'open-project', machineId: 'machine-1', path: '' }), + ).toBeNull(); + expect(parseRemoteDispatchRequest({ kind: 'list-machines', legacy: true })).toBeNull(); + expect( + parseRemoteDispatchRequest({ + kind: 'save-machine', + machine: { name: 'Build box', host: 'build-box', password: 'secret' }, + }), + ).toBeNull(); + }); +}); diff --git a/packages/desktop/src/main/remote-project-open.ts b/packages/desktop/src/main/remote-project-open.ts new file mode 100644 index 000000000..f2b8e6a7f --- /dev/null +++ b/packages/desktop/src/main/remote-project-open.ts @@ -0,0 +1,111 @@ +import type { SshMachine } from '@inkeep/open-knowledge-core'; +import type { MessageBoxOptions, MessageBoxReturnValue } from 'electron'; +import type { OkRemoteDispatchRequest } from '../shared/ipc-channels.ts'; +import type { RemoteProjectInspection } from './remote-project-service.ts'; + +const CANCEL_BUTTON = 0; +const INITIALIZE_BUTTON = 1; + +interface RemoteProjectOpenDecision { + readonly path: string; + readonly initialize: boolean; +} + +interface RemoteProjectOpenDeps { + showInitializationDialog( + options: MessageBoxOptions, + ): Promise>; +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function hasExactKeys(value: Record, keys: readonly string[]): boolean { + const actual = Object.keys(value); + return actual.length === keys.length && keys.every((key) => Object.hasOwn(value, key)); +} + +function nonEmptyString(value: unknown): value is string { + return typeof value === 'string' && value.length > 0; +} + +/** Parse the complete Navigator remote IPC protocol without dropping unknown fields. */ +export function parseRemoteDispatchRequest(value: unknown): OkRemoteDispatchRequest | null { + if (typeof value !== 'object' || value === null || Array.isArray(value)) return null; + const request = value as Record; + switch (request.kind) { + case 'list-machines': + return hasExactKeys(request, ['kind']) ? { kind: 'list-machines' } : null; + case 'save-machine': { + if (!hasExactKeys(request, ['kind', 'machine']) || !isRecord(request.machine)) return null; + const machine = request.machine; + const allowed = new Set(['id', 'name', 'host', 'port']); + if ( + !Object.hasOwn(machine, 'name') || + !Object.hasOwn(machine, 'host') || + Object.keys(machine).some((key) => !allowed.has(key)) || + typeof machine.name !== 'string' || + typeof machine.host !== 'string' || + (Object.hasOwn(machine, 'id') && typeof machine.id !== 'string') || + (Object.hasOwn(machine, 'port') && typeof machine.port !== 'number') + ) { + return null; + } + return { + kind: 'save-machine', + machine: { + ...(Object.hasOwn(machine, 'id') ? { id: machine.id as string } : {}), + name: machine.name, + host: machine.host, + ...(Object.hasOwn(machine, 'port') ? { port: machine.port as number } : {}), + }, + }; + } + case 'remove-machine': + case 'test-machine': + return hasExactKeys(request, ['kind', 'machineId']) && nonEmptyString(request.machineId) + ? { kind: request.kind, machineId: request.machineId } + : null; + case 'list-directories': + case 'open-project': + return hasExactKeys(request, ['kind', 'machineId', 'path']) && + nonEmptyString(request.machineId) && + nonEmptyString(request.path) + ? { kind: request.kind, machineId: request.machineId, path: request.path } + : null; + default: + return null; + } +} + +/** + * Keep remote initialization authority in desktop main. The renderer may ask + * to open a path, but it cannot inspect the project or authorize writes. + */ +export async function resolveRemoteProjectOpen( + machine: SshMachine, + inspection: RemoteProjectInspection, + deps: RemoteProjectOpenDeps, +): Promise { + if (inspection.initialized) { + return { path: inspection.projectPath, initialize: false }; + } + + const result = await deps.showInitializationDialog({ + type: 'warning', + title: 'Initialize remote project?', + message: `Initialize OpenKnowledge on ${machine.name}?`, + detail: `Remote path: ${inspection.selectedPath}\n\nOpenKnowledge will create these missing files:\n\u2022 .ok/config.yml\n\u2022 .ok/.gitignore\n\u2022 .okignore\n\nExisting .ok/config.yml and .okignore files are never overwritten. An existing .ok/.gitignore may receive missing runtime ignore entries.`, + buttons: ['Cancel', 'Initialize and open'], + defaultId: CANCEL_BUTTON, + cancelId: CANCEL_BUTTON, + noLink: true, + }); + + if (result.response === CANCEL_BUTTON) return null; + if (result.response === INITIALIZE_BUTTON) { + return { path: inspection.selectedPath, initialize: true }; + } + throw new Error('Remote project initialization dialog returned an invalid response.'); +} diff --git a/packages/desktop/src/main/remote-project-service.ts b/packages/desktop/src/main/remote-project-service.ts new file mode 100644 index 000000000..712e860e2 --- /dev/null +++ b/packages/desktop/src/main/remote-project-service.ts @@ -0,0 +1,2407 @@ +/** + * SSH transport for desktop-hosted remote projects. + * + * The renderer never receives SSH credentials or arbitrary command arguments. + * This module accepts an allowlisted, non-secret machine record and delegates + * authentication, host-key verification, ProxyJump, and agent use to the + * system OpenSSH client. Every local process is spawned with `shell: false`; + * remote shell strings are built here from fixed commands and POSIX-quoted + * data. The sole intentional command input is the consent-gated terminal + * `launchCommand`, matching the existing local PTY surface. + * + * Effects are injected so the protocol and lifecycle can be tested without a + * real SSH daemon, network socket, or child process. + */ + +import { spawn as nodeSpawn } from 'node:child_process'; +import { createHash, randomBytes } from 'node:crypto'; +import { readFile } from 'node:fs/promises'; +import { createServer } from 'node:net'; +import { StringDecoder } from 'node:string_decoder'; + +import { + isSafeSshDestination, + PROTOCOL_VERSION, + type RemoteDirectoryListing, + type SshConnectionTestResult, + type SshMachine, +} from '@inkeep/open-knowledge-core'; +import { MIN_GIT_VERSION, RUNTIME_VERSION } from '@inkeep/open-knowledge-server'; + +export const REMOTE_READY_MARKER = 'OK_REMOTE_READY '; +export const REMOTE_ERROR_MARKER = 'OK_REMOTE_ERROR '; +export const REMOTE_INSPECT_MARKER = 'OK_REMOTE_INSPECT '; +export const REMOTE_DIRECTORIES_MARKER = 'OK_REMOTE_DIRECTORIES '; +export const REMOTE_TEST_MARKER = 'OK_REMOTE_TEST_V1 '; +export const REMOTE_COMMAND_MARKER = 'OK_REMOTE_COMMAND_V1 '; +export const REMOTE_TERMINAL_CONSENT_MARKER = 'OK_REMOTE_TERMINAL_CONSENT '; +export const REMOTE_COMPANION_MARKER = 'OK_REMOTE_COMPANION_V1 '; +export const REMOTE_TUNNEL_READY_MARKER = 'OK_REMOTE_TUNNEL_READY '; + +const DEFAULT_COMMAND_TIMEOUT_MS = 15_000; +const DEFAULT_INSTALL_TIMEOUT_MS = 120_000; +const DEFAULT_READY_TIMEOUT_MS = 30_000; +const DEFAULT_TUNNEL_READY_TIMEOUT_MS = 12_000; +const DEFAULT_POLL_INTERVAL_MS = 100; +const DEFAULT_FETCH_TIMEOUT_MS = 2_000; +const DEFAULT_MAX_OUTPUT_BYTES = 256 * 1024; +const DEFAULT_MAX_MARKER_BYTES = 16 * 1024; +const DEFAULT_CONNECT_TIMEOUT_SECONDS = 10; +const DEFAULT_SHUTDOWN_GRACE_MS = 1_500; +const MAX_MACHINE_ID_LENGTH = 256; +const MAX_MACHINE_NAME_LENGTH = 256; +const MAX_HOST_LENGTH = 512; +const MAX_REMOTE_PATH_LENGTH = 16 * 1024; +const MAX_RUNTIME_VERSION_LENGTH = 128; +const MAX_DIRECTORY_ENTRIES = 10_000; +const REMOTE_INSTALL_ERROR = + 'OpenKnowledge could not install remote support. Ensure the SSH home is writable and `~/.ok` is owned by that user, is not a symlink, and is not group- or world-writable.'; + +function minimumGitMajorMinor(): readonly [number, number] { + const [major, minor] = MIN_GIT_VERSION.split('.').map(Number); + if (!Number.isInteger(major) || !Number.isInteger(minor)) { + throw new Error(`Invalid MIN_GIT_VERSION: ${MIN_GIT_VERSION}`); + } + return [major as number, minor as number]; +} + +const [MIN_GIT_MAJOR, MIN_GIT_MINOR] = minimumGitMajorMinor(); + +/** Absolute on Unix so a packaged app does not depend on its sanitized PATH. */ +export const DEFAULT_SSH_PATH = process.platform === 'win32' ? 'ssh.exe' : '/usr/bin/ssh'; + +export type RemoteProjectErrorCode = + | 'invalid-machine' + | 'invalid-path' + | 'ssh-unavailable' + | 'ssh-failed' + | 'timeout' + | 'output-limit' + | 'invalid-response' + | 'protocol-mismatch' + | 'unsupported-platform' + | 'prerequisite-missing' + | 'prerequisite-outdated' + | 'companion-install-failed' + | 'local-port-failed' + | 'tunnel-failed' + | 'project-uninitialized' + | 'project-initialize-failed' + | 'config-invalid' + | 'content-dir-outside-project' + | 'startup-failed'; + +/** + * Safe error surfaced by this transport. `message` never embeds child output, + * machine destinations, or project paths. `diagnostic` is private transport + * input used only to map a connection-test failure to generic UI copy. + */ +export class RemoteProjectError extends Error { + readonly code: RemoteProjectErrorCode; + readonly diagnostic?: string; + + constructor( + code: RemoteProjectErrorCode, + message: string, + options?: { cause?: unknown; diagnostic?: string }, + ) { + super(message, options?.cause === undefined ? undefined : { cause: options.cause }); + this.name = 'RemoteProjectError'; + this.code = code; + this.diagnostic = options?.diagnostic; + } +} + +export interface RemoteReadyPayloadV1 { + readonly v: 1; + readonly nonce: string; + readonly port: number; + readonly projectPath: string; + readonly platform: 'darwin' | 'linux'; + readonly pathSeparator: '/'; + readonly protocolVersion: number; + readonly runtimeVersion: string; + readonly capabilities: readonly string[]; + readonly owned: true; +} + +export interface RemoteProjectInspection { + readonly selectedPath: string; + readonly projectPath: string; + readonly initialized: boolean; +} + +export interface RemoteProjectSession { + readonly localPort: number; + readonly apiOrigin: string; + readonly collabUrl: string; + readonly projectPath: string; + readonly platform: 'darwin' | 'linux'; + readonly pathSeparator: '/'; + readonly owned: true; + /** Hash of the effective OpenSSH destination/config used for this session. */ + readonly connectionFingerprint: string; + /** + * Idempotently closes only this session's local port-forward process. + */ + closeTunnel(): void; + /** + * Idempotently closes only this session's owned remote companion process. + */ + closeServer(): void; + /** Idempotently closes both this session's tunnel and server process. */ + close(): void; +} + +interface RemoteProcessStream { + on(event: 'data', listener: (chunk: unknown) => void): unknown; +} + +interface RemoteProcessStdin { + end(data?: Uint8Array): void; + on?(event: 'error', listener: (error: Error) => void): unknown; +} + +/** Minimal child-process surface used by the transport and its test doubles. */ +export interface RemoteChildProcess { + readonly stdin: RemoteProcessStdin | null; + readonly stdout: RemoteProcessStream | null; + readonly stderr: RemoteProcessStream | null; + once(event: string, listener: (...args: unknown[]) => void): unknown; + kill(signal?: NodeJS.Signals): boolean; +} + +export interface RemoteSpawnOptions { + readonly shell: false; + readonly stdio: readonly ['ignore' | 'pipe', 'pipe', 'pipe']; + readonly windowsHide: true; +} + +export type RemoteSpawn = ( + file: string, + args: readonly string[], + options: RemoteSpawnOptions, +) => RemoteChildProcess; + +interface RemoteFetchResponse { + readonly ok: boolean; + readonly status: number; + text(maxBytes: number): Promise; +} + +export type RemoteFetch = ( + url: string, + init: { readonly method: 'GET'; readonly signal: AbortSignal; readonly redirect: 'error' }, +) => Promise; + +export interface RemoteProjectServiceDeps { + readonly spawn?: RemoteSpawn; + readonly fetch?: RemoteFetch; + readonly allocateLocalPort?: () => Promise; + readonly sleep?: (ms: number) => Promise; + /** Cryptographically random per-command challenge. Injectable for tests. */ + readonly createNonce?: () => string; + readonly sshPath?: string; + readonly expectedProtocolVersion?: number; + readonly connectTimeoutSeconds?: number; + readonly commandTimeoutMs?: number; + readonly installTimeoutMs?: number; + readonly readyTimeoutMs?: number; + readonly tunnelReadyTimeoutMs?: number; + readonly pollIntervalMs?: number; + readonly fetchTimeoutMs?: number; + readonly maxOutputBytes?: number; + readonly maxMarkerBytes?: number; + /** Grace after closing an owned server's stdin before SIGTERM fallback. */ + readonly shutdownGraceMs?: number; + /** Test seam for the local `ssh -G` forwarding-policy preflight. */ + readonly inspectTunnelConfig?: (machine: SshMachine) => Promise; + /** Packaged single-file companion. Production passes its Resources path. */ + readonly remoteCompanionPath?: string; + /** Test seam for loading the packaged companion without filesystem IO. */ + readonly loadRemoteCompanion?: () => Promise; + /** Test seam for cases that exercise transport behavior, not installation. */ + readonly ensureRemoteCompanion?: (machine: SshMachine) => Promise; +} + +interface ManagedProcess { + readonly child: RemoteChildProcess; + readonly state: { + ended: boolean; + error: Error | null; + code: number | null; + diagnostic: string; + }; +} + +interface CommandResult { + readonly stdout: string; + readonly stderr: string; +} + +export type RemoteMachinePrerequisiteStatus = + | 'ok' + | 'platform-unsupported' + | 'node-missing' + | 'node-too-old' + | 'git-missing' + | 'git-too-old'; + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +const REMOTE_NONCE = /^[A-Za-z0-9_-]{43}$/; + +export function createRemoteNonce(): string { + return randomBytes(32).toString('base64url'); +} + +function validateRemoteNonce(value: unknown): string { + if (typeof value !== 'string' || !REMOTE_NONCE.test(value)) { + throw new RemoteProjectError('invalid-response', 'Remote command nonce is invalid.'); + } + return value; +} + +function hasExactKeys(value: Record, keys: readonly string[]): boolean { + const actual = Object.keys(value); + return actual.length === keys.length && keys.every((key) => actual.includes(key)); +} + +// biome-ignore lint/suspicious/noControlCharactersInRegex: validating IPC/child-process text. +const CONTROL_CHARACTER = /[\x00-\x1F\x7F]/; + +function boundedTrimmedText( + value: unknown, + field: string, + maxLength: number, + code: RemoteProjectErrorCode, +): string { + if (typeof value !== 'string') { + throw new RemoteProjectError(code, `${field} must be a string.`); + } + const trimmed = value.trim(); + if (trimmed.length === 0 || trimmed.length > maxLength || CONTROL_CHARACTER.test(trimmed)) { + throw new RemoteProjectError(code, `${field} is invalid.`); + } + return trimmed; +} + +/** Validate untrusted wire text without changing filesystem-significant spaces. */ +function boundedWireText( + value: unknown, + field: string, + maxLength: number, + code: RemoteProjectErrorCode, +): string { + if ( + typeof value !== 'string' || + value.length === 0 || + value.length > maxLength || + CONTROL_CHARACTER.test(value) + ) { + throw new RemoteProjectError(code, `${field} is invalid.`); + } + return value; +} + +function boundedCanonicalRemotePath(value: unknown, field: string): string { + const path = boundedWireText(value, field, MAX_REMOTE_PATH_LENGTH, 'invalid-response'); + if (!path.startsWith('/')) { + throw new RemoteProjectError('invalid-response', `${field} is not an absolute path.`); + } + return path; +} + +/** + * Validate and copy the non-secret persisted machine shape. Unknown keys are + * rejected rather than forwarded, preventing a future renderer payload from + * smuggling passwords, identity paths, ProxyCommand, or arbitrary SSH args. + */ +export function validateSshMachine(value: unknown): SshMachine { + if (!isRecord(value)) { + throw new RemoteProjectError('invalid-machine', 'SSH machine must be an object.'); + } + const allowed = new Set(['id', 'name', 'host', 'port']); + if (Object.keys(value).some((key) => !allowed.has(key))) { + throw new RemoteProjectError('invalid-machine', 'SSH machine contains unsupported fields.'); + } + + const id = boundedTrimmedText(value.id, 'Machine id', MAX_MACHINE_ID_LENGTH, 'invalid-machine'); + const name = boundedTrimmedText( + value.name, + 'Machine name', + MAX_MACHINE_NAME_LENGTH, + 'invalid-machine', + ); + const host = boundedTrimmedText(value.host, 'SSH host', MAX_HOST_LENGTH, 'invalid-machine'); + if (value.host !== host || !isSafeSshDestination(host)) { + throw new RemoteProjectError('invalid-machine', 'SSH host is unsafe.'); + } + + const port = value.port; + if ( + port !== undefined && + (!Number.isInteger(port) || (port as number) < 1 || (port as number) > 65_535) + ) { + throw new RemoteProjectError('invalid-machine', 'SSH port must be between 1 and 65535.'); + } + return port === undefined ? { id, name, host } : { id, name, host, port: port as number }; +} + +/** Accept only POSIX absolute paths or a home-relative `~` / `~/...` path. */ +export function validateRemoteProjectPath(value: unknown): string { + if (typeof value !== 'string') { + throw new RemoteProjectError('invalid-path', 'Remote project path must be a string.'); + } + if ( + value.length === 0 || + value.length > MAX_REMOTE_PATH_LENGTH || + value.includes('\0') || + CONTROL_CHARACTER.test(value) || + !(value.startsWith('/') || value === '~' || value.startsWith('~/')) + ) { + throw new RemoteProjectError( + 'invalid-path', + 'Remote project path must be absolute or start with ~/.', + ); + } + return value; +} + +/** POSIX single-quote encoding. Safe for an arbitrary non-NUL string. */ +export function quotePosix(value: string): string { + return `'${value.replaceAll("'", `'"'"'`)}'`; +} + +/** + * Run a fixed command through the remote user's configured login-interactive + * shell so every operation sees the same PATH as the built-in terminal. This + * is important for nvm and zsh setups that initialize PATH from interactive + * startup files. The SSH server's shell interprets the outer command; + * `innerCommand` is POSIX-quoted into exactly one `-c` value. + */ +export function buildPosixLoginShellCommand(innerCommand: string): string { + return `exec "\${SHELL:-/bin/sh}" -lic ${quotePosix(innerCommand)}`; +} + +function emitRemoteTestStatus(nonce: string, status: RemoteMachinePrerequisiteStatus): string { + return `printf '%s\\n' ${quotePosix(`${REMOTE_TEST_MARKER}${JSON.stringify({ v: 1, nonce, status })}`)}`; +} + +/** + * Fixed prerequisite probe used when adding an SSH machine. Desktop installs + * its own companion, so the remote PATH only needs Node.js and Git. The probe + * checks the actual login-interactive PATH used by remote terminals and emits + * one bounded status marker instead of forwarding tool output to the renderer. + */ +export function buildRemoteMachineTestCommand(nonceValue: unknown): string { + const nonce = validateRemoteNonce(nonceValue); + const script = [ + `platform=$(uname -s 2>/dev/null || true)`, + `case "$platform" in Darwin|Linux) ;; *) ${emitRemoteTestStatus(nonce, 'platform-unsupported')}; exit 0;; esac`, + `if ! command -v node >/dev/null 2>&1; then ${emitRemoteTestStatus(nonce, 'node-missing')}; exit 0; fi`, + `node_major=$(node -p ${quotePosix('process.versions.node.split(".")[0]')} 2>/dev/null)`, + `case "$node_major" in ''|*[!0-9]*) ${emitRemoteTestStatus(nonce, 'node-missing')}; exit 0;; esac`, + `if [ "$node_major" -lt 24 ]; then ${emitRemoteTestStatus(nonce, 'node-too-old')}; exit 0; fi`, + `if ! command -v git >/dev/null 2>&1; then ${emitRemoteTestStatus(nonce, 'git-missing')}; exit 0; fi`, + `git_version=$(LC_ALL=C git --version 2>/dev/null); git_version=\${git_version#git version }`, + `git_major=\${git_version%%.*}; git_rest=\${git_version#*.}; git_minor=\${git_rest%%.*}`, + `case "$git_major" in ''|*[!0-9]*) ${emitRemoteTestStatus(nonce, 'git-missing')}; exit 0;; esac`, + `case "$git_minor" in ''|*[!0-9]*) ${emitRemoteTestStatus(nonce, 'git-missing')}; exit 0;; esac`, + `if [ "$git_major" -lt ${MIN_GIT_MAJOR} ] || { [ "$git_major" -eq ${MIN_GIT_MAJOR} ] && [ "$git_minor" -lt ${MIN_GIT_MINOR} ]; }; then ${emitRemoteTestStatus(nonce, 'git-too-old')}; exit 0; fi`, + emitRemoteTestStatus(nonce, 'ok'), + ].join('\n'); + return buildPosixLoginShellCommand(script); +} + +export function parseRemoteMachineTest( + stdout: string, + expectedNonce: string, + maxMarkerBytes: number = DEFAULT_MAX_MARKER_BYTES, +): RemoteMachinePrerequisiteStatus { + const value = matchingRemoteFrame(stdout, REMOTE_TEST_MARKER, expectedNonce, maxMarkerBytes); + if (!value || !hasExactKeys(value, ['v', 'nonce', 'status']) || value.v !== 1) { + throw new RemoteProjectError('invalid-response', 'Remote prerequisite response was invalid.'); + } + const status = value.status; + if ( + status !== 'ok' && + status !== 'platform-unsupported' && + status !== 'node-missing' && + status !== 'node-too-old' && + status !== 'git-missing' && + status !== 'git-too-old' + ) { + throw new RemoteProjectError('invalid-response', 'Remote prerequisite response was invalid.'); + } + return status; +} + +const REMOTE_COMPANION_DIGEST = /^[a-f0-9]{64}$/; + +function validateRemoteCompanionDigest(value: unknown): string { + if (typeof value !== 'string' || !REMOTE_COMPANION_DIGEST.test(value)) { + throw new RemoteProjectError('invalid-response', 'Remote companion identity is invalid.'); + } + return value; +} + +function remoteCompanionPathExpression(digestValue: unknown): string { + const digest = validateRemoteCompanionDigest(digestValue); + return `"$HOME/.ok/remote/servers/${digest}/remote-companion.mjs"`; +} + +/** + * Read-only installation probe. Node performs lstat/ownership/mode/hash checks + * so a symlink or group-writable replacement is never executed as trusted + * Desktop code. Missing or stale content is repaired by the upload step. + */ +export const REMOTE_COMPANION_PROBE_NODE_SCRIPT = String.raw` +const fs = require('node:fs/promises'); +const path = require('node:path'); +const crypto = require('node:crypto'); +const MARKER = ${JSON.stringify(REMOTE_COMPANION_MARKER)}; +const digest = process.argv[1]; +const nonce = process.argv[2]; +const home = process.env.HOME; +function ownedByUser(stat) { + return typeof process.getuid !== 'function' || stat.uid === process.getuid(); +} +function safeDirectory(stat) { + return stat.isDirectory() && !stat.isSymbolicLink() && ownedByUser(stat) && (stat.mode & 0o022) === 0; +} +function privateDirectory(stat) { + return safeDirectory(stat) && (stat.mode & 0o777) === 0o700; +} +async function validInstallation(home, digest) { + const okDir = path.join(home, '.ok'); + const remoteDir = path.join(okDir, 'remote'); + const serversDir = path.join(remoteDir, 'servers'); + const targetDir = path.join(serversDir, digest); + const file = path.join(targetDir, 'remote-companion.mjs'); + try { + for (const dir of [home, okDir]) { + if (!safeDirectory(await fs.lstat(dir))) throw new Error('unsafe managed directory'); + } + for (const dir of [remoteDir, serversDir, targetDir]) { + if (!privateDirectory(await fs.lstat(dir))) throw new Error('unsafe private directory'); + } + const stat = await fs.lstat(file); + if (!stat.isFile() || stat.isSymbolicLink() || !ownedByUser(stat) || (stat.mode & 0o022) !== 0) { + throw new Error('unsafe managed file'); + } + return crypto.createHash('sha256').update(await fs.readFile(file)).digest('hex') === digest; + } catch (error) { + if (error && error.code === 'ENOENT') return false; + throw error; + } +} +(async () => { + if (!/^[a-f0-9]{64}$/.test(digest) || !/^[A-Za-z0-9_-]{43}$/.test(nonce) || !home || !path.isAbsolute(home) || home === path.parse(home).root) { + throw new Error('unsafe home directory'); + } + const status = (await validInstallation(home, digest)) ? 'ready' : 'missing'; + process.stdout.write(MARKER + JSON.stringify({ v: 1, nonce, status }) + '\n'); +})().catch(() => { + process.stderr.write('OK_REMOTE_COMPANION_PROBE_ERROR\n'); + process.exitCode = 1; +}); +`.trim(); + +/** + * Permission-safe user install. The payload arrives on stdin, is bounded and + * SHA-256 verified, then lands through a private staging directory + atomic + * rename. No sudo, shell profile, PATH, npm directory, or project file changes. + */ +export const REMOTE_COMPANION_INSTALL_NODE_SCRIPT = String.raw` +const fs = require('node:fs/promises'); +const path = require('node:path'); +const crypto = require('node:crypto'); +const MARKER = ${JSON.stringify(REMOTE_COMPANION_MARKER)}; +const digest = process.argv[1]; +const expectedBytes = Number(process.argv[2]); +const nonce = process.argv[3]; +const home = process.env.HOME; +let stage = null; +function ownedByUser(stat) { + return typeof process.getuid !== 'function' || stat.uid === process.getuid(); +} +function safeDirectory(stat) { + return stat.isDirectory() && !stat.isSymbolicLink() && ownedByUser(stat) && (stat.mode & 0o022) === 0; +} +async function assertOwnedDirectory(dir, create, makePrivate) { + let stat; + try { + stat = await fs.lstat(dir); + } catch (error) { + if (!error || error.code !== 'ENOENT') throw error; + if (!create) throw error; + try { + await fs.mkdir(dir, { mode: 0o700 }); + } catch (mkdirError) { + if (!mkdirError || mkdirError.code !== 'EEXIST') throw mkdirError; + } + stat = await fs.lstat(dir); + } + if (!safeDirectory(stat)) throw new Error('managed directory is unsafe'); + if (makePrivate && (stat.mode & 0o777) !== 0o700) { + await fs.chmod(dir, 0o700); + stat = await fs.lstat(dir); + if (!safeDirectory(stat) || (stat.mode & 0o777) !== 0o700) { + throw new Error('managed directory permissions are unsafe'); + } + } +} +async function readPayload() { + const chunks = []; + let bytes = 0; + for await (const chunk of process.stdin) { + const value = Buffer.from(chunk); + bytes += value.length; + if (bytes > expectedBytes) throw new Error('payload exceeded declared size'); + chunks.push(value); + } + if (bytes !== expectedBytes) throw new Error('payload size mismatch'); + const payload = Buffer.concat(chunks, bytes); + if (crypto.createHash('sha256').update(payload).digest('hex') !== digest) { + throw new Error('payload digest mismatch'); + } + return payload; +} +async function validTarget(dir, file) { + try { + const dirStat = await fs.lstat(dir); + if (!safeDirectory(dirStat) || (dirStat.mode & 0o777) !== 0o700) { + throw new Error('target directory is unsafe'); + } + const stat = await fs.lstat(file); + if (!stat.isFile() || stat.isSymbolicLink() || !ownedByUser(stat) || (stat.mode & 0o022) !== 0) { + throw new Error('target file is unsafe'); + } + return crypto.createHash('sha256').update(await fs.readFile(file)).digest('hex') === digest; + } catch (error) { + if (error && error.code === 'ENOENT') return false; + throw error; + } +} +(async () => { + if (!/^[a-f0-9]{64}$/.test(digest) || !Number.isSafeInteger(expectedBytes) || expectedBytes < 1 || !/^[A-Za-z0-9_-]{43}$/.test(nonce)) { + throw new Error('invalid install metadata'); + } + if (!home || !path.isAbsolute(home) || home === path.parse(home).root) { + throw new Error('unsafe home directory'); + } + const payload = await readPayload(); + const okDir = path.join(home, '.ok'); + const remoteDir = path.join(okDir, 'remote'); + const serversDir = path.join(remoteDir, 'servers'); + const targetDir = path.join(serversDir, digest); + const targetFile = path.join(targetDir, 'remote-companion.mjs'); + await assertOwnedDirectory(home, false, false); + await assertOwnedDirectory(okDir, true, false); + await assertOwnedDirectory(remoteDir, true, true); + await assertOwnedDirectory(serversDir, true, true); + if (await validTarget(targetDir, targetFile)) { + await fs.chmod(targetFile, 0o600); + process.stdout.write(MARKER + JSON.stringify({ v: 1, nonce, status: 'ready' }) + '\n'); + return; + } + try { + const targetStat = await fs.lstat(targetDir); + if (!safeDirectory(targetStat)) { + throw new Error('target directory is unsafe'); + } + await fs.rm(targetDir, { recursive: true, force: false }); + } catch (error) { + if (!error || error.code !== 'ENOENT') throw error; + } + stage = await fs.mkdtemp(path.join(serversDir, '.' + digest + '-')); + await fs.chmod(stage, 0o700); + const stagedFile = path.join(stage, 'remote-companion.mjs'); + const handle = await fs.open(stagedFile, 'wx', 0o600); + try { + await handle.writeFile(payload); + await handle.sync(); + } finally { + await handle.close(); + } + await fs.chmod(stagedFile, 0o600); + try { + await fs.rename(stage, targetDir); + stage = null; + } catch (error) { + if (!error || (error.code !== 'EEXIST' && error.code !== 'ENOTEMPTY')) throw error; + await fs.rm(stage, { recursive: true, force: true }); + stage = null; + if (!(await validTarget(targetDir, targetFile))) throw new Error('concurrent install was invalid'); + } + process.stdout.write(MARKER + JSON.stringify({ v: 1, nonce, status: 'installed' }) + '\n'); +})().catch(async () => { + if (stage) await fs.rm(stage, { recursive: true, force: true }).catch(() => {}); + process.stderr.write('OK_REMOTE_COMPANION_INSTALL_ERROR\n'); + process.exitCode = 1; +}); +`.trim(); + +export function buildRemoteCompanionProbeCommand( + digestValue: unknown, + nonceValue: unknown, +): string { + const digest = validateRemoteCompanionDigest(digestValue); + const nonce = validateRemoteNonce(nonceValue); + return buildPosixLoginShellCommand( + `node -e ${quotePosix(REMOTE_COMPANION_PROBE_NODE_SCRIPT)} ${quotePosix(digest)} ${quotePosix(nonce)}`, + ); +} + +export function buildRemoteCompanionInstallCommand( + digestValue: unknown, + byteLength: number, + nonceValue: unknown, +): string { + const digest = validateRemoteCompanionDigest(digestValue); + const nonce = validateRemoteNonce(nonceValue); + if (!Number.isSafeInteger(byteLength) || byteLength < 1) { + throw new RemoteProjectError('invalid-response', 'Remote companion size is invalid.'); + } + return buildPosixLoginShellCommand( + `node -e ${quotePosix(REMOTE_COMPANION_INSTALL_NODE_SCRIPT)} ${quotePosix(digest)} ${quotePosix(String(byteLength))} ${quotePosix(nonce)}`, + ); +} + +export function parseRemoteCompanionStatus( + stdout: string, + expectedNonce: string, +): 'ready' | 'missing' | 'installed' { + const value = matchingRemoteFrame( + stdout, + REMOTE_COMPANION_MARKER, + expectedNonce, + DEFAULT_MAX_MARKER_BYTES, + ); + if (!value || !hasExactKeys(value, ['v', 'nonce', 'status']) || value.v !== 1) { + throw new RemoteProjectError('invalid-response', 'Remote companion response was invalid.'); + } + const status = value.status; + if (status === 'ready' || status === 'missing' || status === 'installed') return status; + throw new RemoteProjectError('invalid-response', 'Remote companion response was invalid.'); +} + +const REMOTE_EXECUTABLE_NAME = /^[A-Za-z0-9][A-Za-z0-9._+-]{0,63}$/; + +export function validateRemoteExecutableName(value: unknown): string { + if (typeof value !== 'string' || !REMOTE_EXECUTABLE_NAME.test(value)) { + throw new RemoteProjectError('invalid-response', 'Remote executable name is invalid.'); + } + return value; +} + +export function buildIsCommandAvailableCommand(binValue: unknown, nonceValue: unknown): string { + const bin = validateRemoteExecutableName(binValue); + const nonce = validateRemoteNonce(nonceValue); + const inner = [ + `if command -v ${quotePosix(bin)} >/dev/null 2>&1; then`, + ` printf '%s\\n' ${quotePosix(`${REMOTE_COMMAND_MARKER}${JSON.stringify({ v: 1, nonce, available: true })}`)}`, + 'else', + ` printf '%s\\n' ${quotePosix(`${REMOTE_COMMAND_MARKER}${JSON.stringify({ v: 1, nonce, available: false })}`)}`, + 'fi', + ].join('\n'); + return buildPosixLoginShellCommand(inner); +} + +function parseRemoteCommandAvailability( + stdout: string, + expectedNonce: string, + maxMarkerBytes: number, +): boolean { + const value = matchingRemoteFrame(stdout, REMOTE_COMMAND_MARKER, expectedNonce, maxMarkerBytes); + if ( + !value || + !hasExactKeys(value, ['v', 'nonce', 'available']) || + value.v !== 1 || + typeof value.available !== 'boolean' + ) { + throw new RemoteProjectError('invalid-response', 'Remote command probe was invalid.'); + } + return value.available; +} + +function remotePathShellExpression(remotePath: string): string { + const path = validateRemoteProjectPath(remotePath); + if (path === '~' || path === '~/') return '"$HOME"'; + if (path.startsWith('~/')) return `"$HOME"/${quotePosix(path.slice(2))}`; + return quotePosix(path); +} + +/** Fixed, argument-safe remote command used to inspect Desktop's companion project state. */ +export function buildRemoteInspectCommand( + remotePath: string, + digestValue: unknown, + nonceValue: unknown, +): string { + const companion = remoteCompanionPathExpression(digestValue); + const nonce = validateRemoteNonce(nonceValue); + const inner = `cd ${remotePathShellExpression(remotePath)} && OK_CONSOLE_LEVEL=silent exec node --no-warnings ${companion} --nonce ${quotePosix(nonce)} inspect`; + return buildPosixLoginShellCommand(inner); +} + +/** Fixed, argument-safe remote command used to launch Desktop's companion. */ +export function buildRemoteServeCommand( + remotePath: string, + digestValue: unknown, + options: { + readonly initialize: boolean; + readonly nonce: string; + readonly waitForOwnerExit?: boolean; + }, +): string { + const { initialize, nonce: nonceValue, waitForOwnerExit = false } = options; + if (typeof initialize !== 'boolean' || typeof waitForOwnerExit !== 'boolean') { + throw new RemoteProjectError('invalid-response', 'Remote initialization choice is invalid.'); + } + if (initialize && waitForOwnerExit) { + throw new RemoteProjectError( + 'invalid-response', + 'Remote initialization cannot replace a running project.', + ); + } + const nonce = validateRemoteNonce(nonceValue); + const path = validateRemoteProjectPath(remotePath); + if (initialize && !path.startsWith('/')) { + throw new RemoteProjectError( + 'invalid-path', + 'Remote initialization requires the canonical absolute folder path returned by inspection.', + ); + } + const expectedPath = Buffer.from(path, 'utf8').toString('base64url'); + if (initialize && expectedPath.length > DEFAULT_MAX_MARKER_BYTES) { + throw new RemoteProjectError('invalid-path', 'Remote project path is too long to initialize.'); + } + const companion = remoteCompanionPathExpression(digestValue); + const initializeArgs = initialize + ? ` --initialize --expected-path ${quotePosix(expectedPath)}` + : ''; + const replacementArg = waitForOwnerExit ? ' --wait-for-owner-exit' : ''; + const inner = `cd ${remotePathShellExpression(path)} && OK_CONSOLE_LEVEL=silent exec node --no-warnings ${companion} --nonce ${quotePosix(nonce)} serve${initializeArgs}${replacementArg}`; + return buildPosixLoginShellCommand(inner); +} + +/** Fixed remote command that re-checks the project-local terminal opt-out. */ +export function buildRemoteTerminalConsentCommand( + remotePath: string, + digestValue: unknown, + nonceValue: unknown, +): string { + const companion = remoteCompanionPathExpression(digestValue); + const nonce = validateRemoteNonce(nonceValue); + const inner = `cd ${remotePathShellExpression(remotePath)} && OK_CONSOLE_LEVEL=silent exec node --no-warnings ${companion} --nonce ${quotePosix(nonce)} terminal-consent`; + return buildPosixLoginShellCommand(inner); +} + +/** + * Constant Node program used by the remote folder browser. The only input is a + * base64url JSON argument. It canonicalizes the requested directory and emits + * exactly one versioned marker; errors are deliberately path-free. + */ +export const REMOTE_LIST_DIRECTORIES_NODE_SCRIPT = String.raw` +const fs = require('node:fs/promises'); +const os = require('node:os'); +const path = require('node:path'); +const MAX_ENTRIES = ${MAX_DIRECTORY_ENTRIES}; +let responseNonce = ''; +(async () => { + const input = JSON.parse(Buffer.from(process.argv[1], 'base64url').toString('utf8')); + if (!input || input.v !== 1 || typeof input.path !== 'string' || !/^[A-Za-z0-9_-]{43}$/.test(input.nonce)) throw new Error('bad input'); + const nonce = input.nonce; + responseNonce = nonce; + let requested = input.path; + if (requested === '~') requested = os.homedir(); + else if (requested.startsWith('~/')) requested = path.join(os.homedir(), requested.slice(2)); + if (!path.isAbsolute(requested)) throw new Error('path must be absolute'); + const canonicalPath = await fs.realpath(requested); + if (!(await fs.stat(canonicalPath)).isDirectory()) throw new Error('not a directory'); + const directories = []; + let entries = 0; + for await (const entry of await fs.opendir(canonicalPath)) { + entries += 1; + if (entries > MAX_ENTRIES) { + process.stdout.write(${JSON.stringify(REMOTE_DIRECTORIES_MARKER)} + JSON.stringify({ v: 1, nonce, error: 'too-many' }) + '\n'); + return; + } + const childPath = path.join(canonicalPath, entry.name); + let isDirectory = entry.isDirectory(); + if (!isDirectory && entry.isSymbolicLink()) { + try { + isDirectory = (await fs.stat(childPath)).isDirectory(); + } catch (error) { + if (!error || (error.code !== 'ENOENT' && error.code !== 'ENOTDIR')) throw error; + isDirectory = false; + } + } + if (isDirectory) directories.push({ name: entry.name, path: childPath }); + } + directories.sort((a, b) => a.name.localeCompare(b.name)); + const parent = path.dirname(canonicalPath); + const result = { + v: 1, + nonce, + canonicalPath, + parentPath: parent === canonicalPath ? null : parent, + directories, + }; + process.stdout.write(${JSON.stringify(REMOTE_DIRECTORIES_MARKER)} + JSON.stringify(result) + '\n'); +})().catch(() => { + process.stdout.write(${JSON.stringify(REMOTE_DIRECTORIES_MARKER)} + JSON.stringify({ v: 1, nonce: responseNonce, error: 'failed' }) + '\n'); +}); +`.trim(); + +export function encodeRemoteDirectoryRequest(remotePath: string, nonceValue: unknown): string { + const path = validateRemoteProjectPath(remotePath); + const nonce = validateRemoteNonce(nonceValue); + return Buffer.from(JSON.stringify({ v: 1, nonce, path }), 'utf8').toString('base64url'); +} + +export function buildListDirectoriesCommand(remotePath: string, nonceValue: unknown): string { + const payload = encodeRemoteDirectoryRequest(remotePath, nonceValue); + const inner = `node -e ${quotePosix(REMOTE_LIST_DIRECTORIES_NODE_SCRIPT)} ${quotePosix(payload)}`; + return buildPosixLoginShellCommand(inner); +} + +function commonSshArgs(connectTimeoutSeconds: number): string[] { + return [ + '-o', + 'BatchMode=yes', + '-o', + `ConnectTimeout=${connectTimeoutSeconds}`, + '-o', + 'ServerAliveInterval=15', + '-o', + 'ServerAliveCountMax=3', + '-o', + 'RemoteCommand=none', + '-o', + // A saved `SessionType=none` suppresses every fixed remote command, + // including the tunnel readiness sentinel. Desktop always needs a normal + // SSH session for probes, the remote server, terminals, and the sentinel. + 'SessionType=default', + // Preserve config aliases, ProxyJump, identities, and agent-based auth, + // while refusing unrelated long-lived capabilities inherited from a Host + // block. ControlPath=none is what prevents attachment to a persistent + // multiplex master; ControlMaster=no alone does not. + '-o', + 'ForwardAgent=no', + '-o', + 'ForwardX11=no', + '-o', + 'ForwardX11Trusted=no', + '-o', + 'PermitLocalCommand=no', + '-o', + 'ControlMaster=no', + '-o', + 'ControlPath=none', + '-o', + 'ControlPersist=no', + '-o', + 'Tunnel=no', + '-o', + 'StdinNull=no', + '-o', + 'ForkAfterAuthentication=no', + ]; +} + +function portArgs(machine: SshMachine): string[] { + return machine.port === undefined ? [] : ['-p', String(machine.port)]; +} + +/** + * SSH argv for a bounded remote command. `ClearAllForwardings` prevents saved + * config forwards from being activated by one-shot probes while retaining all + * authentication, host, ProxyJump, and agent configuration. + */ +export function buildSshCommandArgs( + machineValue: unknown, + remoteCommand: string, + connectTimeoutSeconds: number = DEFAULT_CONNECT_TIMEOUT_SECONDS, +): string[] { + const machine = validateSshMachine(machineValue); + return [ + ...commonSshArgs(connectTimeoutSeconds), + '-o', + 'ClearAllForwardings=yes', + '-T', + ...portArgs(machine), + '--', + machine.host, + remoteCommand, + ]; +} + +/** Resolve effective Host configuration before starting a long-lived tunnel. */ +export function buildSshEffectiveConfigArgs( + machineValue: unknown, + connectTimeoutSeconds: number = DEFAULT_CONNECT_TIMEOUT_SECONDS, +): string[] { + const machine = validateSshMachine(machineValue); + return [ + ...commonSshArgs(connectTimeoutSeconds), + // Inspect the exact forwarding posture the tunnel will use. Leaving an + // inherited `ClearAllForwardings=yes` in force can hide configured + // forwards from `ssh -G`; the tunnel's matching `no` would then re-enable + // them after the safety preflight. + '-o', + 'ClearAllForwardings=no', + '-G', + ...portArgs(machine), + '--', + machine.host, + ]; +} + +/** + * Reject Host aliases that would activate unrelated configured forwards. + * `ClearAllForwardings=yes` cannot be used by the tunnel process because it + * also clears Desktop's explicit loopback `-L`, so `ssh -G` is the reliable + * preflight boundary. + */ +export function assertSafeTunnelSshConfig(effectiveConfig: string): void { + const forbidden = new Set(['localforward', 'remoteforward', 'dynamicforward']); + for (const line of effectiveConfig.split(/\r?\n/)) { + const key = line.trimStart().split(/\s+/, 1)[0]?.toLowerCase(); + if (key && forbidden.has(key)) { + throw new RemoteProjectError( + 'invalid-machine', + 'This SSH Host config defines port forwarding. Add a clean Host alias without LocalForward, RemoteForward, or DynamicForward.', + ); + } + } +} + +/** Hash only endpoint/routing fields whose drift can redirect later SSH work. */ +export function fingerprintTunnelSshConfig(effectiveConfig: string): string { + assertSafeTunnelSshConfig(effectiveConfig); + const identityKeys = new Set([ + 'hostname', + 'user', + 'port', + 'hostkeyalias', + 'proxyjump', + 'proxycommand', + 'canonicalizehostname', + 'canonicaldomains', + ]); + const identity = effectiveConfig + .split(/\r?\n/) + .map((line) => line.trim()) + .filter((line) => { + const key = line.split(/\s+/, 1)[0]?.toLowerCase(); + return key !== undefined && identityKeys.has(key); + }) + .join('\n'); + if (!/(?:^|\n)hostname\s+\S+/i.test(identity) || !/(?:^|\n)port\s+\d+/i.test(identity)) { + throw new RemoteProjectError( + 'invalid-response', + 'The system SSH client returned an invalid effective Host configuration.', + ); + } + return createHash('sha256').update(identity, 'utf8').digest('hex'); +} + +/** + * SSH argv for the dedicated loopback-only forwarding process. The fixed + * remote `cat` sentinel ties the session lifetime to piped stdin: if Desktop + * closes or crashes, EOF reaches the remote command and SSH tears down the + * forward instead of surviving as an orphaned `ssh -N` process. + */ +export function buildSshTunnelArgs( + machineValue: unknown, + localPort: number, + remotePort: number, + nonceValue: unknown, + connectTimeoutSeconds: number = DEFAULT_CONNECT_TIMEOUT_SECONDS, +): string[] { + const machine = validateSshMachine(machineValue); + const nonce = validateRemoteNonce(nonceValue); + if (!isValidPort(localPort) || !isValidPort(remotePort)) { + throw new RemoteProjectError('tunnel-failed', 'SSH tunnel port is invalid.'); + } + return [ + ...commonSshArgs(connectTimeoutSeconds), + '-o', + // The tunnel needs Desktop's explicit -L even when the selected Host alias + // inherits `ClearAllForwardings=yes`. The effective-config preflight uses + // the same override and rejects every unrelated configured forward. + 'ClearAllForwardings=no', + '-o', + 'ExitOnForwardFailure=yes', + '-T', + '-L', + `127.0.0.1:${localPort}:127.0.0.1:${remotePort}`, + ...portArgs(machine), + '--', + machine.host, + buildRemoteTunnelSentinelCommand(nonce), + ]; +} + +export function buildRemoteTunnelSentinelCommand(nonceValue: unknown): string { + const nonce = validateRemoteNonce(nonceValue); + const frame = `${REMOTE_TUNNEL_READY_MARKER}${JSON.stringify({ v: 1, nonce, ready: true })}`; + return `printf '%s\\n' ${quotePosix(frame)}; exec cat >/dev/null`; +} + +/** + * SSH argv for a docked remote terminal. `launchCommand` is the same consent- + * gated command surface used by the local PTY host: when non-empty, the remote + * login-interactive shell runs it and then replaces itself with a fresh login- + * interactive shell so the tab remains usable after the agent exits. + */ +export function buildSshTerminalArgs( + machineValue: unknown, + remotePath: string, + launchCommand?: string, + connectTimeoutSeconds: number = DEFAULT_CONNECT_TIMEOUT_SECONDS, +): string[] { + const machine = validateSshMachine(machineValue); + const shellTail = `exec "\${SHELL:-/bin/sh}" -l -i`; + const launch = + launchCommand !== undefined && launchCommand.length > 0 ? `${launchCommand}; ` : ''; + const inner = `cd ${remotePathShellExpression(remotePath)} && ${launch}${shellTail}`; + const remoteCommand = buildPosixLoginShellCommand(inner); + return [ + ...commonSshArgs(connectTimeoutSeconds), + '-o', + 'ClearAllForwardings=yes', + '-tt', + ...portArgs(machine), + '--', + machine.host, + remoteCommand, + ]; +} + +function isValidPort(value: unknown): value is number { + return Number.isInteger(value) && (value as number) >= 1 && (value as number) <= 65_535; +} + +function parseJsonObject(json: string, code: RemoteProjectErrorCode): Record { + let parsed: unknown; + try { + parsed = JSON.parse(json); + } catch (cause) { + throw new RemoteProjectError(code, 'Remote response was not valid JSON.', { cause }); + } + if (!isRecord(parsed)) { + throw new RemoteProjectError(code, 'Remote response had an invalid shape.'); + } + return parsed; +} + +function matchingRemoteFrameLine( + line: string, + marker: string, + expectedNonce: string, + maxMarkerBytes: number, +): Record | null { + const nonce = validateRemoteNonce(expectedNonce); + const normalized = line.endsWith('\r') ? line.slice(0, -1) : line; + if (!normalized.startsWith(marker) || Buffer.byteLength(normalized, 'utf8') > maxMarkerBytes) { + return null; + } + let parsed: unknown; + try { + parsed = JSON.parse(normalized.slice(marker.length)); + } catch { + return null; + } + return isRecord(parsed) && parsed.nonce === nonce ? parsed : null; +} + +function matchingRemoteFrame( + stdout: string, + marker: string, + expectedNonce: string, + maxMarkerBytes: number, +): Record | null { + for (const line of stdout.split(/\r?\n/)) { + const value = matchingRemoteFrameLine(line, marker, expectedNonce, maxMarkerBytes); + if (value) return value; + } + return null; +} + +function remoteCompanionError(code: unknown): RemoteProjectError { + if (code === 'project-uninitialized') { + return new RemoteProjectError( + code, + 'The selected folder is not an OpenKnowledge project. Initialize it before opening.', + ); + } + if (code === 'project-initialize-failed') { + return new RemoteProjectError( + code, + 'OpenKnowledge could not initialize the selected remote folder. Check that it is writable and does not contain a symlinked `.ok` directory.', + ); + } + if (code === 'config-invalid') { + return new RemoteProjectError(code, 'The remote project configuration is invalid.'); + } + if (code === 'content-dir-outside-project') { + return new RemoteProjectError( + code, + 'The remote project content directory must stay inside the project folder.', + ); + } + if (code === 'startup-failed') { + return new RemoteProjectError(code, 'The remote OpenKnowledge server could not start.'); + } + throw new RemoteProjectError('invalid-response', 'Remote companion error was invalid.'); +} + +/** Parse one structured companion error frame. Non-marker lines return `null`. */ +export function parseRemoteErrorLine( + line: string, + expectedNonce: string, + maxMarkerBytes: number = DEFAULT_MAX_MARKER_BYTES, +): RemoteProjectError | null { + const value = matchingRemoteFrameLine(line, REMOTE_ERROR_MARKER, expectedNonce, maxMarkerBytes); + if (!value) return null; + if (value.v !== 1 || !hasExactKeys(value, ['v', 'nonce', 'code'])) { + throw new RemoteProjectError('invalid-response', 'Remote companion error was invalid.'); + } + return remoteCompanionError(value.code); +} + +function parseRemoteErrorOutput( + stdout: string, + expectedNonce: string, + maxMarkerBytes: number, +): RemoteProjectError | null { + for (const line of stdout.split(/\r?\n/)) { + const error = parseRemoteErrorLine(line, expectedNonce, maxMarkerBytes); + if (error) return error; + } + return null; +} + +export function parseRemoteInspection( + stdout: string, + expectedNonce: string, + maxMarkerBytes: number = DEFAULT_MAX_MARKER_BYTES, +): RemoteProjectInspection { + const value = matchingRemoteFrame(stdout, REMOTE_INSPECT_MARKER, expectedNonce, maxMarkerBytes); + if (!value) { + throw new RemoteProjectError('invalid-response', 'Remote project inspection was missing.'); + } + if ( + value.v !== 1 || + typeof value.initialized !== 'boolean' || + !hasExactKeys(value, ['v', 'nonce', 'selectedPath', 'projectPath', 'initialized']) + ) { + throw new RemoteProjectError('invalid-response', 'Remote project inspection was invalid.'); + } + return { + selectedPath: boundedCanonicalRemotePath(value.selectedPath, 'Selected remote path'), + projectPath: boundedCanonicalRemotePath(value.projectPath, 'Remote project path'), + initialized: value.initialized, + }; +} + +/** Parse one marker line. Non-marker lines return `null`; malformed markers throw. */ +export function parseRemoteReadyLine( + line: string, + expectedNonce: string, + expectedProtocolVersion: number = PROTOCOL_VERSION, + maxMarkerBytes: number = DEFAULT_MAX_MARKER_BYTES, +): RemoteReadyPayloadV1 | null { + const value = matchingRemoteFrameLine(line, REMOTE_READY_MARKER, expectedNonce, maxMarkerBytes); + if (!value) return null; + if (value.v !== 1) { + throw new RemoteProjectError('invalid-response', 'Unsupported remote readiness version.'); + } + if (!isValidPort(value.port)) { + throw new RemoteProjectError('invalid-response', 'Remote readiness port is invalid.'); + } + const projectPath = boundedCanonicalRemotePath(value.projectPath, 'Remote project path'); + if (value.platform !== 'darwin' && value.platform !== 'linux') { + throw new RemoteProjectError('protocol-mismatch', 'The remote platform is not supported.'); + } + if (value.pathSeparator !== '/') { + throw new RemoteProjectError( + 'protocol-mismatch', + 'The remote path separator is not supported.', + ); + } + if (!Number.isInteger(value.protocolVersion) || (value.protocolVersion as number) < 1) { + throw new RemoteProjectError('invalid-response', 'Remote protocol version is invalid.'); + } + if (value.protocolVersion !== expectedProtocolVersion) { + throw new RemoteProjectError( + 'protocol-mismatch', + 'The remote OpenKnowledge protocol is incompatible with this app.', + ); + } + const runtimeVersion = boundedWireText( + value.runtimeVersion, + 'Remote runtime version', + MAX_RUNTIME_VERSION_LENGTH, + 'invalid-response', + ); + if (runtimeVersion !== RUNTIME_VERSION) { + throw new RemoteProjectError( + 'protocol-mismatch', + 'The remote OpenKnowledge runtime is incompatible with this app.', + ); + } + if ( + !Array.isArray(value.capabilities) || + value.capabilities.length !== 2 || + value.capabilities[0] !== 'http' || + value.capabilities[1] !== 'ws' + ) { + throw new RemoteProjectError( + 'protocol-mismatch', + 'The remote OpenKnowledge server does not support this app.', + ); + } + if ( + value.owned !== true || + !hasExactKeys(value, [ + 'v', + 'nonce', + 'port', + 'projectPath', + 'platform', + 'pathSeparator', + 'protocolVersion', + 'runtimeVersion', + 'capabilities', + 'owned', + ]) + ) { + throw new RemoteProjectError('invalid-response', 'Remote ownership flag is invalid.'); + } + return { + v: 1, + nonce: expectedNonce, + port: value.port, + projectPath, + platform: value.platform, + pathSeparator: '/', + protocolVersion: value.protocolVersion as number, + runtimeVersion, + capabilities: [...value.capabilities], + owned: true, + }; +} + +function parseDirectoryPayload( + stdout: string, + expectedNonce: string, + maxPayloadBytes: number, +): RemoteDirectoryListing { + const raw = matchingRemoteFrame( + stdout, + REMOTE_DIRECTORIES_MARKER, + expectedNonce, + maxPayloadBytes, + ); + if (!raw) { + throw new RemoteProjectError('invalid-response', 'Remote directory response was missing.'); + } + if (raw.v !== 1) { + throw new RemoteProjectError('invalid-response', 'Unsupported remote directory version.'); + } + if (raw.error === 'too-many') { + if (!hasExactKeys(raw, ['v', 'nonce', 'error'])) { + throw new RemoteProjectError('invalid-response', 'Remote directory response was invalid.'); + } + throw new RemoteProjectError('output-limit', 'The remote folder contains too many entries.'); + } + if (raw.error === 'failed') { + if (!hasExactKeys(raw, ['v', 'nonce', 'error'])) { + throw new RemoteProjectError('invalid-response', 'Remote directory response was invalid.'); + } + throw new RemoteProjectError('ssh-failed', 'OpenKnowledge could not read that remote folder.'); + } + if (raw.error !== undefined) { + throw new RemoteProjectError('invalid-response', 'Remote directory response was invalid.'); + } + if (!hasExactKeys(raw, ['v', 'nonce', 'canonicalPath', 'parentPath', 'directories'])) { + throw new RemoteProjectError('invalid-response', 'Remote directory response was invalid.'); + } + const canonicalPath = boundedCanonicalRemotePath(raw.canonicalPath, 'Canonical remote path'); + let parentPath: string | null; + if (raw.parentPath === null) { + parentPath = null; + } else { + parentPath = boundedCanonicalRemotePath(raw.parentPath, 'Remote parent path'); + } + if (!Array.isArray(raw.directories) || raw.directories.length > MAX_DIRECTORY_ENTRIES) { + throw new RemoteProjectError('invalid-response', 'Remote directory list is invalid.'); + } + const directories = raw.directories.map((entry) => { + if (!isRecord(entry)) { + throw new RemoteProjectError('invalid-response', 'Remote directory entry is invalid.'); + } + return { + name: boundedWireText(entry.name, 'Remote directory name', 1024, 'invalid-response'), + path: boundedCanonicalRemotePath(entry.path, 'Remote directory path'), + }; + }); + return { path: canonicalPath, parentPath, directories }; +} + +export function parseRemoteTerminalConsent( + stdout: string, + expectedNonce: string, + maxMarkerBytes: number = DEFAULT_MAX_MARKER_BYTES, +): boolean { + const raw = matchingRemoteFrame( + stdout, + REMOTE_TERMINAL_CONSENT_MARKER, + expectedNonce, + maxMarkerBytes, + ); + if (!raw) { + throw new RemoteProjectError('invalid-response', 'Remote terminal consent was missing.'); + } + if ( + raw.v !== 1 || + typeof raw.allowed !== 'boolean' || + !hasExactKeys(raw, ['v', 'nonce', 'allowed']) + ) { + throw new RemoteProjectError('invalid-response', 'Remote terminal consent was invalid.'); + } + return raw.allowed; +} + +/** Reserve a kernel-selected IPv4 loopback port, then release it for OpenSSH. */ +function allocateLoopbackPort(): Promise { + return new Promise((resolve, reject) => { + const server = createServer(); + server.unref(); + server.once('error', (cause) => { + reject( + new RemoteProjectError('local-port-failed', 'Could not allocate a local port.', { + cause, + }), + ); + }); + server.listen({ host: '127.0.0.1', port: 0, exclusive: true }, () => { + const address = server.address(); + const port = typeof address === 'object' && address !== null ? address.port : 0; + server.close((cause) => { + if (cause || !isValidPort(port)) { + reject( + new RemoteProjectError('local-port-failed', 'Could not allocate a local port.', { + cause, + }), + ); + return; + } + resolve(port); + }); + }); + }); +} + +function defaultSpawn( + file: string, + args: readonly string[], + options: RemoteSpawnOptions, +): RemoteChildProcess { + return nodeSpawn(file, [...args], { + shell: options.shell, + stdio: [...options.stdio], + windowsHide: options.windowsHide, + }) as unknown as RemoteChildProcess; +} + +async function defaultFetch( + url: string, + init: { readonly method: 'GET'; readonly signal: AbortSignal; readonly redirect: 'error' }, +): Promise { + const response = await fetch(url, init); + return { + ok: response.ok, + status: response.status, + text: (maxBytes) => readBoundedResponseText(response, maxBytes), + }; +} + +/** Read a fetch body without ever buffering beyond the caller's byte cap. */ +export async function readBoundedResponseText( + response: Pick, + maxBytes: number, +): Promise { + if (!Number.isInteger(maxBytes) || maxBytes < 1 || response.body === null) { + throw new RemoteProjectError('invalid-response', 'Remote API response was invalid.'); + } + const reader = response.body.getReader(); + const decoder = new StringDecoder('utf8'); + let bytes = 0; + let text = ''; + try { + while (true) { + const { done, value } = await reader.read(); + if (done) return text + decoder.end(); + bytes += value.byteLength; + if (bytes > maxBytes) { + await reader.cancel().catch(() => {}); + throw new RemoteProjectError('output-limit', 'Remote API response was too large.'); + } + text += decoder.write(value); + } + } finally { + reader.releaseLock(); + } +} + +function defaultSleep(ms: number): Promise { + return new Promise((resolve) => { + const timer = setTimeout(resolve, ms); + timer.unref(); + }); +} + +function isRetryableConnectionFailure(error: unknown): boolean { + return ( + error instanceof TypeError || + (error instanceof DOMException && + (error.name === 'AbortError' || error.name === 'TimeoutError')) + ); +} + +function chunkToBuffer(chunk: unknown): Buffer { + if (typeof chunk === 'string') return Buffer.from(chunk, 'utf8'); + if (chunk instanceof Uint8Array) return Buffer.from(chunk); + return Buffer.from(String(chunk), 'utf8'); +} + +function decodeChunk(decoder: StringDecoder, chunk: unknown): { text: string; bytes: number } { + const buffer = chunkToBuffer(chunk); + return { text: decoder.write(buffer), bytes: buffer.byteLength }; +} + +function safeKill(child: RemoteChildProcess | undefined): void { + if (!child) return; + try { + child.kill('SIGTERM'); + } catch { + // Closing a process is best-effort and idempotent; it may already be gone. + } +} + +function sshTestError(error: unknown): string { + if (!(error instanceof RemoteProjectError)) return 'Could not connect to the SSH machine.'; + const diagnostic = error.diagnostic?.toLowerCase() ?? ''; + if (error.code === 'invalid-machine') return error.message; + if (error.code === 'unsupported-platform') return error.message; + if (error.code === 'prerequisite-missing' || error.code === 'prerequisite-outdated') { + return error.message; + } + if (error.code === 'companion-install-failed') return error.message; + if (error.code === 'timeout' || diagnostic.includes('timed out')) { + return 'The SSH connection timed out.'; + } + if (diagnostic.includes('host key verification failed')) { + return 'SSH host-key verification failed.'; + } + if (diagnostic.includes('permission denied') || diagnostic.includes('publickey')) { + return 'SSH authentication failed.'; + } + if ( + diagnostic.includes('could not resolve hostname') || + diagnostic.includes('name or service not known') + ) { + return 'The SSH host could not be resolved.'; + } + if (diagnostic.includes('connection refused')) return 'The SSH connection was refused.'; + if (error.code === 'ssh-unavailable') return 'The system SSH client is unavailable.'; + if (error.code === 'invalid-response') { + return 'The SSH machine returned an invalid prerequisite response.'; + } + return 'Could not connect to the SSH machine.'; +} + +function prerequisiteError(status: RemoteMachinePrerequisiteStatus): RemoteProjectError | null { + if (status === 'ok') return null; + if (status === 'platform-unsupported') { + return new RemoteProjectError( + 'unsupported-platform', + 'OpenKnowledge remote projects support macOS and Linux SSH machines.', + ); + } + if (status === 'node-missing') { + return new RemoteProjectError( + 'prerequisite-missing', + 'Install Node.js 24 or newer on the SSH machine.', + ); + } + if (status === 'node-too-old') { + return new RemoteProjectError( + 'prerequisite-outdated', + 'Update Node.js on the SSH machine to version 24 or newer.', + ); + } + if (status === 'git-missing') { + return new RemoteProjectError( + 'prerequisite-missing', + `Install Git ${MIN_GIT_VERSION} or newer on the SSH machine.`, + ); + } + return new RemoteProjectError( + 'prerequisite-outdated', + `Update Git on the SSH machine to version ${MIN_GIT_VERSION} or newer.`, + ); +} + +/** Main-process owner of SSH probes, remote-server processes, and tunnels. */ +export class RemoteProjectService { + private readonly spawn: RemoteSpawn; + private readonly fetch: RemoteFetch; + private readonly allocateLocalPort: () => Promise; + private readonly sleep: (ms: number) => Promise; + private readonly createNonce: () => string; + private readonly sshPath: string; + private readonly expectedProtocolVersion: number; + private readonly connectTimeoutSeconds: number; + private readonly commandTimeoutMs: number; + private readonly installTimeoutMs: number; + private readonly readyTimeoutMs: number; + private readonly tunnelReadyTimeoutMs: number; + private readonly pollIntervalMs: number; + private readonly fetchTimeoutMs: number; + private readonly maxOutputBytes: number; + private readonly maxMarkerBytes: number; + private readonly shutdownGraceMs: number; + private readonly inspectTunnelConfig: (machine: SshMachine) => Promise; + private readonly remoteCompanionPath: string | undefined; + private readonly loadRemoteCompanion: (() => Promise) | undefined; + private readonly ensureRemoteCompanionOverride: + | ((machine: SshMachine) => Promise) + | undefined; + private remoteCompanionArtifactPromise: + | Promise<{ readonly bytes: Uint8Array; readonly digest: string }> + | undefined; + private readonly remoteCompanionInstallPromises = new Map>(); + + constructor(deps: RemoteProjectServiceDeps = {}) { + this.spawn = deps.spawn ?? defaultSpawn; + this.fetch = deps.fetch ?? defaultFetch; + this.allocateLocalPort = deps.allocateLocalPort ?? allocateLoopbackPort; + this.sleep = deps.sleep ?? defaultSleep; + this.createNonce = deps.createNonce ?? createRemoteNonce; + this.sshPath = deps.sshPath ?? DEFAULT_SSH_PATH; + this.expectedProtocolVersion = deps.expectedProtocolVersion ?? PROTOCOL_VERSION; + this.connectTimeoutSeconds = Math.max( + 1, + Math.floor(deps.connectTimeoutSeconds ?? DEFAULT_CONNECT_TIMEOUT_SECONDS), + ); + this.commandTimeoutMs = Math.max(1, deps.commandTimeoutMs ?? DEFAULT_COMMAND_TIMEOUT_MS); + this.installTimeoutMs = Math.max(1, deps.installTimeoutMs ?? DEFAULT_INSTALL_TIMEOUT_MS); + this.readyTimeoutMs = Math.max(1, deps.readyTimeoutMs ?? DEFAULT_READY_TIMEOUT_MS); + this.tunnelReadyTimeoutMs = Math.max( + 1, + deps.tunnelReadyTimeoutMs ?? DEFAULT_TUNNEL_READY_TIMEOUT_MS, + ); + this.pollIntervalMs = Math.max(1, deps.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS); + this.fetchTimeoutMs = Math.max(1, deps.fetchTimeoutMs ?? DEFAULT_FETCH_TIMEOUT_MS); + this.maxOutputBytes = Math.max(1024, deps.maxOutputBytes ?? DEFAULT_MAX_OUTPUT_BYTES); + this.maxMarkerBytes = Math.max(256, deps.maxMarkerBytes ?? DEFAULT_MAX_MARKER_BYTES); + this.shutdownGraceMs = Math.max(1, deps.shutdownGraceMs ?? DEFAULT_SHUTDOWN_GRACE_MS); + this.inspectTunnelConfig = + deps.inspectTunnelConfig ?? ((machine) => this.inspectEffectiveTunnelConfig(machine)); + this.remoteCompanionPath = deps.remoteCompanionPath; + this.loadRemoteCompanion = deps.loadRemoteCompanion; + this.ensureRemoteCompanionOverride = deps.ensureRemoteCompanion; + } + + async testMachine(machineValue: unknown): Promise { + try { + const machine = validateSshMachine(machineValue); + await this.inspectTunnelConfig(machine); + await this.assertPrerequisites(machine); + await this.ensureRemoteCompanion(machine); + return { ok: true }; + } catch (error) { + return { ok: false, error: sshTestError(error) }; + } + } + + async listDirectories( + machineValue: unknown, + remotePath: string, + ): Promise { + const machine = validateSshMachine(machineValue); + const nonce = this.nextNonce(); + const command = buildListDirectoriesCommand(remotePath, nonce); + const result = await this.runCommand(machine, command, this.commandTimeoutMs); + return parseDirectoryPayload(result.stdout, nonce, this.maxOutputBytes); + } + + async isCommandAvailable( + machineValue: unknown, + binValue: unknown, + expectedConnectionFingerprint?: string, + ): Promise { + const machine = validateSshMachine(machineValue); + await this.assertConnectionFingerprint(machine, expectedConnectionFingerprint); + const nonce = this.nextNonce(); + const command = buildIsCommandAvailableCommand(binValue, nonce); + const result = await this.runCommand(machine, command, this.commandTimeoutMs); + return parseRemoteCommandAvailability(result.stdout, nonce, this.maxMarkerBytes); + } + + async isTerminalAllowed( + machineValue: unknown, + remotePath: string, + expectedConnectionFingerprint?: string, + ): Promise { + const machine = validateSshMachine(machineValue); + await this.assertConnectionFingerprint(machine, expectedConnectionFingerprint); + const companionDigest = await this.ensureRemoteCompanion(machine); + const nonce = this.nextNonce(); + const command = buildRemoteTerminalConsentCommand(remotePath, companionDigest, nonce); + const result = await this.runCommand(machine, command, this.commandTimeoutMs, nonce); + return parseRemoteTerminalConsent(result.stdout, nonce, this.maxMarkerBytes); + } + + async inspectProject( + machineValue: unknown, + remotePath: string, + ): Promise { + const machine = validateSshMachine(machineValue); + await this.assertPrerequisites(machine); + const companionDigest = await this.ensureRemoteCompanion(machine); + const nonce = this.nextNonce(); + const result = await this.runCommand( + machine, + buildRemoteInspectCommand(remotePath, companionDigest, nonce), + this.commandTimeoutMs, + nonce, + ); + return parseRemoteInspection(result.stdout, nonce, this.maxMarkerBytes); + } + + async startProject( + machineValue: unknown, + remotePath: string, + options: { + readonly initialize: boolean; + readonly expectedConnectionFingerprint?: string; + readonly waitForOwnerExit?: boolean; + }, + ): Promise { + const { initialize, expectedConnectionFingerprint, waitForOwnerExit = false } = options; + const machine = validateSshMachine(machineValue); + const connectionFingerprint = await this.inspectTunnelConfig(machine); + if ( + expectedConnectionFingerprint !== undefined && + connectionFingerprint !== expectedConnectionFingerprint + ) { + throw new RemoteProjectError( + 'invalid-machine', + 'This SSH Host configuration changed after the project opened. Close and reopen the project before reconnecting remote tools.', + ); + } + if (typeof initialize !== 'boolean' || typeof waitForOwnerExit !== 'boolean') { + throw new RemoteProjectError('invalid-response', 'Remote initialization choice is invalid.'); + } + await this.assertPrerequisites(machine); + const companionDigest = await this.ensureRemoteCompanion(machine); + const nonce = this.nextNonce(); + const command = buildRemoteServeCommand(remotePath, companionDigest, { + initialize, + nonce, + waitForOwnerExit, + }); + let server: ManagedProcess | undefined; + let tunnel: ManagedProcess | undefined; + try { + server = this.spawnManaged( + buildSshCommandArgs(machine, command, this.connectTimeoutSeconds), + 'pipe', + ); + const ready = await this.waitForReady(server.child, nonce); + if (server.state.ended || server.state.error) { + throw new RemoteProjectError('ssh-failed', 'Remote OpenKnowledge server exited.'); + } + + const localPort = await this.allocateLocalPort(); + if (!isValidPort(localPort)) { + throw new RemoteProjectError('local-port-failed', 'Could not allocate a local port.'); + } + const tunnelNonce = this.nextNonce(); + tunnel = this.spawnManaged( + buildSshTunnelArgs(machine, localPort, ready.port, tunnelNonce, this.connectTimeoutSeconds), + 'pipe', + ); + await this.waitForTunnelReady(tunnel, tunnelNonce); + const apiOrigin = `http://127.0.0.1:${localPort}`; + await this.pollApiConfig(apiOrigin, ready.port, server, tunnel); + + let tunnelClosed = false; + let serverClosed = false; + const closeTunnel = (): void => { + if (tunnelClosed) return; + tunnelClosed = true; + if (tunnel) this.closePipedProcess(tunnel); + }; + const closeServer = (): void => { + if (serverClosed) return; + serverClosed = true; + if (server) this.closePipedProcess(server); + }; + return { + localPort, + apiOrigin, + collabUrl: `ws://127.0.0.1:${localPort}/collab`, + projectPath: ready.projectPath, + platform: ready.platform, + pathSeparator: ready.pathSeparator, + owned: true, + connectionFingerprint, + closeTunnel, + closeServer, + close: () => { + closeTunnel(); + closeServer(); + }, + }; + } catch (error) { + safeKill(tunnel?.child); + safeKill(server?.child); + if (error instanceof RemoteProjectError) throw error; + throw new RemoteProjectError('ssh-failed', 'Could not start the remote project.', { + cause: error, + }); + } + } + + private spawnManaged( + args: readonly string[], + stdinMode: 'ignore' | 'pipe' = 'ignore', + ): ManagedProcess { + let child: RemoteChildProcess; + try { + child = this.spawn(this.sshPath, args, { + shell: false, + stdio: [stdinMode, 'pipe', 'pipe'], + windowsHide: true, + }); + } catch (cause) { + throw new RemoteProjectError('ssh-unavailable', 'The system SSH client is unavailable.', { + cause, + }); + } + if (!child.stdout || !child.stderr || (stdinMode === 'pipe' && !child.stdin)) { + safeKill(child); + throw new RemoteProjectError('ssh-unavailable', 'The SSH client did not expose output.'); + } + + const state: ManagedProcess['state'] = { + ended: false, + error: null, + code: null, + diagnostic: '', + }; + const diagnosticDecoder = new StringDecoder('utf8'); + let diagnosticBytes = 0; + child.stderr.on('data', (chunk) => { + const decoded = decodeChunk(diagnosticDecoder, chunk); + diagnosticBytes += decoded.bytes; + if (diagnosticBytes > this.maxOutputBytes) { + state.diagnostic = ''; + return; + } + state.diagnostic += decoded.text; + }); + // Always drain stdout. Long-lived tunnel processes should not normally + // write to it, but a configured SSH client must never block on a full pipe. + child.stdout.on('data', () => {}); + child.once('error', (...args) => { + state.error = args[0] instanceof Error ? args[0] : new Error('SSH process error'); + }); + child.once('close', (...args) => { + if (diagnosticBytes <= this.maxOutputBytes) state.diagnostic += diagnosticDecoder.end(); + state.ended = true; + state.code = typeof args[0] === 'number' ? args[0] : null; + }); + return { child, state }; + } + + private closePipedProcess(process: ManagedProcess): void { + if (process.state.ended) return; + if (!process.child.stdin) { + safeKill(process.child); + return; + } + try { + process.child.stdin.end(); + } catch { + safeKill(process.child); + return; + } + const fallback = setTimeout(() => { + if (!process.state.ended) safeKill(process.child); + }, this.shutdownGraceMs); + fallback.unref(); + process.child.once('close', () => clearTimeout(fallback)); + } + + private nextNonce(): string { + return validateRemoteNonce(this.createNonce()); + } + + private async inspectEffectiveTunnelConfig(machine: SshMachine): Promise { + const result = await this.runSshArgs( + buildSshEffectiveConfigArgs(machine, this.connectTimeoutSeconds), + this.commandTimeoutMs, + ); + return fingerprintTunnelSshConfig(result.stdout); + } + + private async assertConnectionFingerprint( + machine: SshMachine, + expectedConnectionFingerprint: string | undefined, + ): Promise { + if (expectedConnectionFingerprint === undefined) return; + const current = await this.inspectTunnelConfig(machine); + if (current !== expectedConnectionFingerprint) { + throw new RemoteProjectError( + 'invalid-machine', + 'This SSH Host configuration changed after the project opened. Close and reopen the project before starting remote tools.', + ); + } + } + + private async companionArtifact(): Promise<{ + readonly bytes: Uint8Array; + readonly digest: string; + }> { + this.remoteCompanionArtifactPromise ??= (async () => { + let bytes: Uint8Array; + try { + if (this.loadRemoteCompanion) { + bytes = await this.loadRemoteCompanion(); + } else if (this.remoteCompanionPath) { + bytes = await readFile(this.remoteCompanionPath); + } else { + throw new Error('Remote companion path was not configured.'); + } + } catch (cause) { + throw new RemoteProjectError( + 'companion-install-failed', + 'OpenKnowledge remote support files are missing. Reinstall OpenKnowledge.', + { cause }, + ); + } + if (bytes.byteLength < 1) { + throw new RemoteProjectError( + 'companion-install-failed', + 'OpenKnowledge remote support files are missing. Reinstall OpenKnowledge.', + ); + } + return { + bytes, + digest: createHash('sha256').update(bytes).digest('hex'), + }; + })(); + return this.remoteCompanionArtifactPromise; + } + + private async assertPrerequisites(machine: SshMachine): Promise { + const nonce = this.nextNonce(); + const result = await this.runCommand( + machine, + buildRemoteMachineTestCommand(nonce), + this.commandTimeoutMs, + ); + const error = prerequisiteError( + parseRemoteMachineTest(result.stdout, nonce, this.maxMarkerBytes), + ); + if (error) throw error; + } + + private async ensureRemoteCompanion(machine: SshMachine): Promise { + if (this.ensureRemoteCompanionOverride) { + return validateRemoteCompanionDigest(await this.ensureRemoteCompanionOverride(machine)); + } + + const artifact = await this.companionArtifact(); + const key = `${machine.id}\0${machine.host}\0${machine.port ?? ''}\0${artifact.digest}`; + const existing = this.remoteCompanionInstallPromises.get(key); + if (existing) return existing; + const install = this.installRemoteCompanion(machine, artifact); + this.remoteCompanionInstallPromises.set(key, install); + try { + return await install; + } finally { + if (this.remoteCompanionInstallPromises.get(key) === install) { + this.remoteCompanionInstallPromises.delete(key); + } + } + } + + private async installRemoteCompanion( + machine: SshMachine, + artifact: { readonly bytes: Uint8Array; readonly digest: string }, + ): Promise { + try { + const probeNonce = this.nextNonce(); + const probe = await this.runCommand( + machine, + buildRemoteCompanionProbeCommand(artifact.digest, probeNonce), + this.commandTimeoutMs, + ); + if (parseRemoteCompanionStatus(probe.stdout, probeNonce) === 'ready') { + return artifact.digest; + } + + const installNonce = this.nextNonce(); + const install = await this.runSshArgs( + buildSshCommandArgs( + machine, + buildRemoteCompanionInstallCommand( + artifact.digest, + artifact.bytes.byteLength, + installNonce, + ), + this.connectTimeoutSeconds, + ), + this.installTimeoutMs, + artifact.bytes, + ); + const status = parseRemoteCompanionStatus(install.stdout, installNonce); + if (status === 'ready' || status === 'installed') return artifact.digest; + } catch (error) { + if ( + error instanceof RemoteProjectError && + (error.code === 'ssh-unavailable' || + error.code === 'timeout' || + error.code === 'output-limit') + ) { + throw error; + } + throw new RemoteProjectError('companion-install-failed', REMOTE_INSTALL_ERROR, { + cause: error, + diagnostic: error instanceof RemoteProjectError ? error.diagnostic : undefined, + }); + } + throw new RemoteProjectError('companion-install-failed', REMOTE_INSTALL_ERROR); + } + + private async runCommand( + machine: SshMachine, + remoteCommand: string, + timeoutMs: number, + expectedNonce?: string, + ): Promise { + return this.runSshArgs( + buildSshCommandArgs(machine, remoteCommand, this.connectTimeoutSeconds), + timeoutMs, + undefined, + expectedNonce, + ); + } + + private async runSshArgs( + args: readonly string[], + timeoutMs: number, + stdin?: Uint8Array, + expectedNonce?: string, + ): Promise { + const managed = this.spawnManaged(args, stdin === undefined ? 'ignore' : 'pipe'); + try { + return await new Promise((resolve, reject) => { + let stdout = ''; + let stderr = ''; + let byteCount = 0; + let settled = false; + const stdoutDecoder = new StringDecoder('utf8'); + const stderrDecoder = new StringDecoder('utf8'); + const timer = setTimeout(() => { + finish( + new RemoteProjectError('timeout', 'The SSH command timed out.', { + diagnostic: stderr, + }), + ); + }, timeoutMs); + timer.unref(); + + const finish = (error?: RemoteProjectError, code?: number | null): void => { + if (settled) return; + settled = true; + clearTimeout(timer); + if (error) { + safeKill(managed.child); + reject(error); + return; + } + if (code !== 0) { + try { + const structured = + expectedNonce === undefined + ? null + : parseRemoteErrorOutput(stdout, expectedNonce, this.maxMarkerBytes); + if (structured) { + reject(structured); + return; + } + } catch (error) { + reject( + error instanceof RemoteProjectError + ? error + : new RemoteProjectError( + 'invalid-response', + 'Remote companion error was invalid.', + ), + ); + return; + } + reject( + new RemoteProjectError('ssh-failed', 'The SSH command failed.', { + diagnostic: stderr || managed.state.diagnostic, + }), + ); + return; + } + resolve({ stdout, stderr }); + }; + + const append = (target: 'stdout' | 'stderr', chunk: unknown): void => { + if (settled) return; + const decoded = decodeChunk(target === 'stdout' ? stdoutDecoder : stderrDecoder, chunk); + byteCount += decoded.bytes; + if (byteCount > this.maxOutputBytes) { + finish(new RemoteProjectError('output-limit', 'SSH command output was too large.')); + return; + } + if (target === 'stdout') stdout += decoded.text; + else stderr += decoded.text; + }; + managed.child.stdout?.on('data', (chunk) => append('stdout', chunk)); + managed.child.stderr?.on('data', (chunk) => append('stderr', chunk)); + managed.child.once('error', (...args) => { + const cause = args[0] instanceof Error ? args[0] : undefined; + finish( + new RemoteProjectError('ssh-unavailable', 'The system SSH client failed.', { + cause, + diagnostic: cause?.message, + }), + ); + }); + managed.child.once('close', (...args) => { + if (!settled) { + stdout += stdoutDecoder.end(); + stderr += stderrDecoder.end(); + } + finish(undefined, typeof args[0] === 'number' ? args[0] : null); + }); + if (stdin !== undefined) { + managed.child.stdin?.on?.('error', (cause) => { + finish( + new RemoteProjectError('ssh-failed', 'The SSH upload failed.', { + cause, + diagnostic: cause.message, + }), + ); + }); + try { + managed.child.stdin?.end(stdin); + } catch (cause) { + finish( + new RemoteProjectError('ssh-failed', 'The SSH upload failed.', { + cause, + diagnostic: cause instanceof Error ? cause.message : undefined, + }), + ); + } + } + }); + } catch (error) { + safeKill(managed.child); + throw error; + } + } + + private waitForReady( + child: RemoteChildProcess, + expectedNonce: string, + ): Promise { + return new Promise((resolve, reject) => { + let pending = ''; + let stderr = ''; + let byteCount = 0; + let settled = false; + const stdoutDecoder = new StringDecoder('utf8'); + const stderrDecoder = new StringDecoder('utf8'); + const timer = setTimeout(() => { + stderr += stderrDecoder.end(); + finish( + undefined, + new RemoteProjectError('timeout', 'Timed out waiting for the remote server.', { + diagnostic: stderr, + }), + ); + }, this.readyTimeoutMs); + timer.unref(); + + const finish = (ready?: RemoteReadyPayloadV1, error?: RemoteProjectError): void => { + if (settled) return; + settled = true; + clearTimeout(timer); + if (error) { + safeKill(child); + reject(error); + } else if (ready) { + resolve(ready); + } + }; + + const consumeLine = (line: string): void => { + if (settled) return; + try { + const remoteError = parseRemoteErrorLine(line, expectedNonce, this.maxMarkerBytes); + if (remoteError) { + finish(undefined, remoteError); + return; + } + const ready = parseRemoteReadyLine( + line, + expectedNonce, + this.expectedProtocolVersion, + this.maxMarkerBytes, + ); + if (ready) finish(ready); + } catch (error) { + finish( + undefined, + error instanceof RemoteProjectError + ? error + : new RemoteProjectError('invalid-response', 'Remote readiness was invalid.'), + ); + } + }; + + child.stdout?.on('data', (chunk) => { + if (settled) return; + const decoded = decodeChunk(stdoutDecoder, chunk); + byteCount += decoded.bytes; + if (byteCount > this.maxOutputBytes) { + finish( + undefined, + new RemoteProjectError('output-limit', 'Remote startup output was too large.'), + ); + return; + } + pending += decoded.text; + let newline = pending.indexOf('\n'); + while (newline >= 0 && !settled) { + consumeLine(pending.slice(0, newline)); + pending = pending.slice(newline + 1); + newline = pending.indexOf('\n'); + } + }); + child.stderr?.on('data', (chunk) => { + if (settled) return; + const decoded = decodeChunk(stderrDecoder, chunk); + byteCount += decoded.bytes; + if (byteCount > this.maxOutputBytes) { + finish( + undefined, + new RemoteProjectError('output-limit', 'Remote startup output was too large.'), + ); + return; + } + stderr += decoded.text; + }); + child.once('error', (...args) => { + const cause = args[0] instanceof Error ? args[0] : undefined; + finish( + undefined, + new RemoteProjectError('ssh-unavailable', 'The system SSH client failed.', { + cause, + diagnostic: cause?.message, + }), + ); + }); + child.once('close', () => { + if (!settled) { + pending += stdoutDecoder.end(); + stderr += stderrDecoder.end(); + } + if (pending.length > 0) consumeLine(pending); + if (!settled) { + finish( + undefined, + new RemoteProjectError('ssh-failed', 'Remote OpenKnowledge server exited.', { + diagnostic: stderr, + }), + ); + } + }); + }); + } + + private waitForTunnelReady(tunnel: ManagedProcess, expectedNonce: string): Promise { + return new Promise((resolve, reject) => { + let pending = ''; + let byteCount = 0; + let settled = false; + const decoder = new StringDecoder('utf8'); + const timer = setTimeout(() => { + finish( + new RemoteProjectError('tunnel-failed', 'Timed out starting the SSH tunnel.', { + diagnostic: tunnel.state.diagnostic, + }), + ); + }, this.tunnelReadyTimeoutMs); + timer.unref(); + + const finish = (error?: RemoteProjectError): void => { + if (settled) return; + settled = true; + clearTimeout(timer); + if (error) { + safeKill(tunnel.child); + reject(error); + } else { + resolve(); + } + }; + const consumeLine = (line: string): void => { + const frame = matchingRemoteFrameLine( + line, + REMOTE_TUNNEL_READY_MARKER, + expectedNonce, + this.maxMarkerBytes, + ); + if ( + frame && + frame.v === 1 && + frame.ready === true && + hasExactKeys(frame, ['v', 'nonce', 'ready']) + ) { + finish(); + } + }; + + tunnel.child.stdout?.on('data', (chunk) => { + if (settled) return; + const decoded = decodeChunk(decoder, chunk); + byteCount += decoded.bytes; + if (byteCount > this.maxOutputBytes) { + finish(new RemoteProjectError('output-limit', 'SSH tunnel output was too large.')); + return; + } + pending += decoded.text; + let newline = pending.indexOf('\n'); + while (newline >= 0 && !settled) { + consumeLine(pending.slice(0, newline)); + pending = pending.slice(newline + 1); + newline = pending.indexOf('\n'); + } + }); + tunnel.child.once('error', (...args) => { + const cause = args[0] instanceof Error ? args[0] : undefined; + finish( + new RemoteProjectError('ssh-unavailable', 'The system SSH client failed.', { + cause, + diagnostic: cause?.message, + }), + ); + }); + tunnel.child.once('close', () => { + if (!settled) pending += decoder.end(); + if (pending.length > 0) consumeLine(pending); + if (!settled) { + finish( + new RemoteProjectError('tunnel-failed', 'The SSH tunnel exited.', { + diagnostic: tunnel.state.diagnostic, + }), + ); + } + }); + }); + } + + private async pollApiConfig( + apiOrigin: string, + remotePort: number, + server: ManagedProcess, + tunnel: ManagedProcess, + ): Promise { + const attempts = Math.max(1, Math.ceil(this.tunnelReadyTimeoutMs / this.pollIntervalMs)); + const deadline = Date.now() + this.tunnelReadyTimeoutMs; + for (let attempt = 0; attempt < attempts; attempt += 1) { + const remainingMs = deadline - Date.now(); + if (remainingMs <= 0) break; + if (server.state.ended || server.state.error) { + throw new RemoteProjectError('ssh-failed', 'Remote OpenKnowledge server exited.', { + diagnostic: server.state.diagnostic, + }); + } + if (tunnel.state.ended || tunnel.state.error) { + throw new RemoteProjectError('tunnel-failed', 'The SSH tunnel exited.', { + diagnostic: tunnel.state.diagnostic, + }); + } + try { + const response = await this.fetch(`${apiOrigin}/api/config`, { + method: 'GET', + signal: AbortSignal.timeout(Math.max(1, Math.min(this.fetchTimeoutMs, remainingMs))), + redirect: 'error', + }); + if (!response.ok) { + throw new RemoteProjectError( + 'invalid-response', + 'The remote OpenKnowledge API returned an unsuccessful response.', + ); + } + const text = await response.text(this.maxMarkerBytes); + if (Buffer.byteLength(text, 'utf8') > this.maxMarkerBytes) { + throw new RemoteProjectError('output-limit', 'Remote API response was too large.'); + } + const body = parseJsonObject(text, 'invalid-response'); + if ( + body.port !== remotePort || + (typeof body.collabUrl !== 'string' && body.collabUrl !== null) + ) { + throw new RemoteProjectError('invalid-response', 'Remote API response was invalid.'); + } + if (server.state.ended || server.state.error) { + throw new RemoteProjectError('ssh-failed', 'Remote OpenKnowledge server exited.'); + } + if (tunnel.state.ended || tunnel.state.error) { + throw new RemoteProjectError('tunnel-failed', 'The SSH tunnel exited.'); + } + return; + } catch (error) { + if (error instanceof RemoteProjectError) throw error; + if (!isRetryableConnectionFailure(error)) { + throw new RemoteProjectError( + 'tunnel-failed', + 'The remote OpenKnowledge API could not be reached through the SSH tunnel.', + { cause: error }, + ); + } + } + if (attempt + 1 < attempts && Date.now() < deadline) { + await this.sleep(Math.min(this.pollIntervalMs, Math.max(1, deadline - Date.now()))); + } + } + throw new RemoteProjectError('timeout', 'Timed out waiting for the SSH tunnel.'); + } +} + +export function createRemoteProjectService( + deps: RemoteProjectServiceDeps = {}, +): RemoteProjectService { + return new RemoteProjectService(deps); +} diff --git a/packages/desktop/src/main/remote-reconnect-coordinator.test.ts b/packages/desktop/src/main/remote-reconnect-coordinator.test.ts new file mode 100644 index 000000000..2faf8257c --- /dev/null +++ b/packages/desktop/src/main/remote-reconnect-coordinator.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, mock, test } from 'bun:test'; +import { RemoteReconnectCoordinator } from './remote-reconnect-coordinator.ts'; + +describe('RemoteReconnectCoordinator', () => { + test('coalesces concurrent reconnects for the same remote project', async () => { + const coordinator = new RemoteReconnectCoordinator(); + let resolve!: (value: { ok: true }) => void; + const reconnect = mock( + () => + new Promise<{ ok: true }>((done) => { + resolve = done; + }), + ); + + const first = coordinator.run('ssh:machine:%2Fsrv%2Fwiki', reconnect); + const second = coordinator.run('ssh:machine:%2Fsrv%2Fwiki', reconnect); + expect(first).toBe(second); + await Promise.resolve(); + expect(reconnect).toHaveBeenCalledTimes(1); + resolve({ ok: true }); + await expect(first).resolves.toEqual({ ok: true }); + }); + + test('clears a failed transaction so a later reconnect can retry', async () => { + const coordinator = new RemoteReconnectCoordinator(); + await expect( + coordinator.run('ssh:machine:%2Fsrv%2Fwiki', async () => { + throw new Error('offline'); + }), + ).rejects.toThrow('offline'); + await Promise.resolve(); + + await expect( + coordinator.run('ssh:machine:%2Fsrv%2Fwiki', async () => ({ ok: true })), + ).resolves.toEqual({ ok: true }); + }); +}); diff --git a/packages/desktop/src/main/remote-reconnect-coordinator.ts b/packages/desktop/src/main/remote-reconnect-coordinator.ts new file mode 100644 index 000000000..eaf3e9ce5 --- /dev/null +++ b/packages/desktop/src/main/remote-reconnect-coordinator.ts @@ -0,0 +1,27 @@ +/** Result shape shared by local/remote Desktop restart IPC. */ +export type RemoteReconnectOutcome = { ok: true } | { ok: false; reason: 'other' }; + +/** + * Coalesce reconnect requests per project before either caller starts SSH. + * This prevents redundant sessions from competing for the exclusive remote + * project lock or main-process fingerprint ownership. + */ +export class RemoteReconnectCoordinator { + private readonly pending = new Map>(); + + run( + projectKey: string, + reconnect: () => Promise, + ): Promise { + const existing = this.pending.get(projectKey); + if (existing) return existing; + + const work = Promise.resolve().then(reconnect); + this.pending.set(projectKey, work); + const clear = (): void => { + if (this.pending.get(projectKey) === work) this.pending.delete(projectKey); + }; + void work.then(clear, clear); + return work; + } +} diff --git a/packages/desktop/src/main/remote-session-lifecycle.test.ts b/packages/desktop/src/main/remote-session-lifecycle.test.ts new file mode 100644 index 000000000..15b4d366d --- /dev/null +++ b/packages/desktop/src/main/remote-session-lifecycle.test.ts @@ -0,0 +1,51 @@ +import { describe, expect, test } from 'bun:test'; +import { createRemoteSessionCleanup } from './remote-session-lifecycle.ts'; + +function makeHarness(authoritative = true) { + const events: string[] = []; + const cleanup = createRemoteSessionCleanup({ + closeAttachedWindows: () => events.push('terminals'), + closeTransport: () => events.push('transport'), + isAuthoritative: () => authoritative, + releaseAuthority: () => events.push('fingerprint'), + }); + return { cleanup, events }; +} + +describe('remote session cleanup', () => { + test('an uncommitted dedup/failure closes only its fresh transport', () => { + const { cleanup, events } = makeHarness(); + + cleanup.close(); + + expect(events).toEqual(['transport']); + }); + + test('a committed authoritative session closes terminals before its transport and fingerprint', () => { + const { cleanup, events } = makeHarness(); + cleanup.commit(); + + cleanup.close(); + + expect(events).toEqual(['terminals', 'transport', 'fingerprint']); + }); + + test('a committed session that lost authority closes only its own transport', () => { + const { cleanup, events } = makeHarness(false); + cleanup.commit(); + + cleanup.close(); + + expect(events).toEqual(['transport']); + }); + + test('cleanup is idempotent', () => { + const { cleanup, events } = makeHarness(); + cleanup.commit(); + + cleanup.close(); + cleanup.close(); + + expect(events).toEqual(['terminals', 'transport', 'fingerprint']); + }); +}); diff --git a/packages/desktop/src/main/remote-session-lifecycle.ts b/packages/desktop/src/main/remote-session-lifecycle.ts new file mode 100644 index 000000000..e6630c096 --- /dev/null +++ b/packages/desktop/src/main/remote-session-lifecycle.ts @@ -0,0 +1,45 @@ +/** + * Commit-gated cleanup for one desktop-owned remote-project transport. + * + * WindowManager may call a fresh session's cleanup before its window is + * committed (load failure or one-window-per-project dedup). In that case only + * the fresh transport belongs to the caller; attached terminal windows and the + * current fingerprint still belong to the already-open editor. Once committed, + * the authoritative owner revokes attached terminals before closing the SSH + * transport and finally releases the fingerprint authority. + */ + +export interface RemoteSessionCleanup { + commit(): void; + close(): void; +} + +export function createRemoteSessionCleanup(deps: { + closeAttachedWindows(): void; + closeTransport(): void; + isAuthoritative(): boolean; + releaseAuthority(): void; +}): RemoteSessionCleanup { + let committed = false; + let closed = false; + + return { + commit(): void { + if (!closed) committed = true; + }, + close(): void { + if (closed) return; + closed = true; + const releaseProject = committed && deps.isAuthoritative(); + try { + if (releaseProject) deps.closeAttachedWindows(); + } finally { + try { + deps.closeTransport(); + } finally { + if (releaseProject) deps.releaseAuthority(); + } + } + }, + }; +} diff --git a/packages/desktop/src/main/state-store.ts b/packages/desktop/src/main/state-store.ts index 551338ace..f57b98728 100644 --- a/packages/desktop/src/main/state-store.ts +++ b/packages/desktop/src/main/state-store.ts @@ -12,6 +12,11 @@ import { existsSync, mkdirSync, renameSync, unlinkSync, writeFileSync } from 'node:fs'; import { join } from 'node:path'; +import { + isSafeSshDestination, + remoteProjectKey, + type SshMachine, +} from '@inkeep/open-knowledge-core'; interface RecentProject { path: string; @@ -30,6 +35,12 @@ interface RecentProject { * existed self-heal without a schema bump. */ gitRemoteUrl?: string; + /** SSH location metadata. `path` above is the opaque machine-scoped key. */ + remote?: { + machineId: string; + machineName: string; + path: string; + }; } /** @@ -93,6 +104,8 @@ export const MAX_SUPPORTED_SCHEMA_VERSION = 1; export interface AppState { /** LRU-capped recent-projects, newest first. */ recentProjects: RecentProject[]; + /** Saved non-secret SSH destinations. Authentication stays in OpenSSH. */ + sshMachines: SshMachine[]; /** Most recently opened project, or null if Navigator was last visible. */ lastOpenedProject: string | null; /** @@ -244,10 +257,78 @@ export interface AppState { } const RECENT_CAP = 20; +const MAX_SSH_MACHINE_ID_LENGTH = 256; +const MAX_SSH_MACHINE_NAME_LENGTH = 256; +const MAX_SSH_HOST_LENGTH = 512; +const MAX_REMOTE_PATH_LENGTH = 16 * 1024; +// biome-ignore lint/suspicious/noControlCharactersInRegex: validating persisted state. +const SSH_CONTROL_CHARACTER = /[\x00-\x1F\x7F]/; + +function parsePersistedSshText(value: unknown, maxLength: number): string | null { + if (typeof value !== 'string') return null; + const trimmed = value.trim(); + if (trimmed.length === 0 || trimmed.length > maxLength || SSH_CONTROL_CHARACTER.test(trimmed)) { + return null; + } + return trimmed; +} + +/** + * Browser-safe counterpart to the endpoint rules in validateSshMachine. + * Persisted state must never turn an invalid explicit port into an implicit + * default-port connection, or restore a host that the IPC boundary rejects. + */ +function parsePersistedSshMachine(value: unknown): SshMachine | null { + if (typeof value !== 'object' || value === null || Array.isArray(value)) return null; + const machine = value as Record; + const id = parsePersistedSshText(machine.id, MAX_SSH_MACHINE_ID_LENGTH); + const name = parsePersistedSshText(machine.name, MAX_SSH_MACHINE_NAME_LENGTH); + const host = parsePersistedSshText(machine.host, MAX_SSH_HOST_LENGTH); + if ( + id === null || + name === null || + host === null || + machine.host !== host || + !isSafeSshDestination(host) + ) { + return null; + } + if ( + machine.port !== undefined && + (!Number.isInteger(machine.port) || + (machine.port as number) < 1 || + (machine.port as number) > 65_535) + ) { + return null; + } + return machine.port === undefined + ? { id, name, host } + : { id, name, host, port: machine.port as number }; +} + +function parsePersistedRemoteProject(value: unknown): NonNullable | null { + if (typeof value !== 'object' || value === null || Array.isArray(value)) return null; + const remote = value as Record; + const machineId = parsePersistedSshText(remote.machineId, MAX_SSH_MACHINE_ID_LENGTH); + const machineName = parsePersistedSshText(remote.machineName, MAX_SSH_MACHINE_NAME_LENGTH); + const path = remote.path; + if ( + machineId === null || + machineName === null || + typeof path !== 'string' || + !path.startsWith('/') || + path.length > MAX_REMOTE_PATH_LENGTH || + SSH_CONTROL_CHARACTER.test(path) + ) { + return null; + } + return { machineId, machineName, path }; +} export function emptyState(): AppState { return { recentProjects: [], + sshMachines: [], lastOpenedProject: null, versionPendingInstall: null, attemptedInstall: null, @@ -411,6 +492,86 @@ export function addRecentProject( return { ...state, recentProjects: updated, lastOpenedProject: projectPath }; } +/** Add or move an SSH-backed project to the front of recents. */ +export function addRecentRemoteProject( + state: AppState, + projectKey: string, + name: string, + machine: SshMachine, + remotePath: string, +): AppState { + const now = new Date().toISOString(); + const filtered = state.recentProjects.filter((p) => p.path !== projectKey); + const entry: RecentProject = { + path: projectKey, + name, + lastOpenedAt: now, + remote: { + machineId: machine.id, + machineName: machine.name, + path: remotePath, + }, + }; + return { + ...state, + recentProjects: [entry, ...filtered].slice(0, RECENT_CAP), + lastOpenedProject: projectKey, + }; +} + +/** Upsert a validated non-secret SSH machine and refresh its recent labels. */ +export function saveSshMachine(state: AppState, machine: SshMachine): AppState { + const existing = state.sshMachines.find((item) => item.id === machine.id); + if (existing && (existing.host !== machine.host || existing.port !== machine.port)) { + // A machine id is part of every remote project/window key. Rebinding it + // in place could leave an existing editor tunneled to the old endpoint + // while a newly opened terminal or reconnect targets the new endpoint. + throw new Error('An SSH machine endpoint cannot be changed in place. Add a new machine.'); + } + const nextMachines = [machine, ...state.sshMachines.filter((item) => item.id !== machine.id)]; + return { + ...state, + sshMachines: nextMachines, + recentProjects: state.recentProjects.map((recent) => + recent.remote?.machineId === machine.id + ? { + ...recent, + remote: { ...recent.remote, machineName: machine.name }, + } + : recent, + ), + }; +} + +/** + * Forget a machine and every recent/session that depends on it. This never + * reaches the remote host and never deletes project data. + */ +export function removeSshMachine(state: AppState, machineId: string): AppState { + const machineProjectPrefix = `ssh:${encodeURIComponent(machineId)}:`; + const projectSessions = { ...state.projectSessions }; + for (const key of Object.keys(projectSessions)) { + if (key.startsWith(machineProjectPrefix)) delete projectSessions[key]; + } + const projectWindowBounds = { ...state.projectWindowBounds }; + for (const key of Object.keys(projectWindowBounds)) { + if (key.startsWith(machineProjectPrefix)) delete projectWindowBounds[key]; + } + return { + ...state, + sshMachines: state.sshMachines.filter((machine) => machine.id !== machineId), + recentProjects: state.recentProjects.filter((recent) => recent.remote?.machineId !== machineId), + lastOpenedProject: + state.lastOpenedProject?.startsWith(machineProjectPrefix) === true + ? null + : state.lastOpenedProject, + projectSessions, + projectWindowBounds, + pendingWindowRestore: + state.pendingWindowRestore?.filter((key) => !key.startsWith(machineProjectPrefix)) ?? null, + }; +} + /** Remove a project from the recent list. */ export function removeRecentProject(state: AppState, projectPath: string): AppState { const projectSessions = { ...state.projectSessions }; @@ -473,7 +634,10 @@ export function annotateMissing( ): RecentProject[] { return state.recentProjects.map((p) => ({ ...p, - missing: !exists(p.path), + // Offline SSH projects remain selectable; connection errors are surfaced + // when opened. A local `existsSync` probe cannot say anything useful about + // a remote path and would otherwise mark every remote recent Missing. + missing: p.remote === undefined ? !exists(p.path) : false, })); } @@ -598,6 +762,20 @@ export function evaluateSchemaCompatibility( export function parseAppState(raw: unknown): AppState | null { if (typeof raw !== 'object' || raw === null) return null; const obj = raw as Record; + + const sshMachines: SshMachine[] = []; + if (obj.sshMachines !== undefined) { + if (!Array.isArray(obj.sshMachines)) return null; + const seen = new Set(); + for (const value of obj.sshMachines) { + const parsed = parsePersistedSshMachine(value); + if (parsed === null || seen.has(parsed.id)) return null; + seen.add(parsed.id); + sshMachines.push(parsed); + } + } + const sshMachinesById = new Map(sshMachines.map((machine) => [machine.id, machine])); + const recentRaw = obj.recentProjects; if (!Array.isArray(recentRaw)) return null; const recentProjects: RecentProject[] = []; @@ -617,6 +795,19 @@ export function parseAppState(raw: unknown): AppState | null { if (typeof item.gitRemoteUrl === 'string' && item.gitRemoteUrl.length > 0) { entry.gitRemoteUrl = item.gitRemoteUrl; } + if (item.remote !== undefined) { + const remote = parsePersistedRemoteProject(item.remote); + const machine = remote ? sshMachinesById.get(remote.machineId) : undefined; + if ( + remote === null || + machine === undefined || + machine.name !== remote.machineName || + item.path !== remoteProjectKey(remote.machineId, remote.path) + ) { + return null; + } + entry.remote = remote; + } recentProjects.push(entry); } } @@ -673,6 +864,7 @@ export function parseAppState(raw: unknown): AppState | null { typeof obj.spellCheckEnabled === 'boolean' ? obj.spellCheckEnabled : true; return { recentProjects, + sshMachines, lastOpenedProject, versionPendingInstall, attemptedInstall, diff --git a/packages/desktop/src/main/terminal-manager.ts b/packages/desktop/src/main/terminal-manager.ts index b5155dcd8..fa987cd85 100644 --- a/packages/desktop/src/main/terminal-manager.ts +++ b/packages/desktop/src/main/terminal-manager.ts @@ -106,6 +106,15 @@ interface TerminalCreateRequest { * Omitted for a plain terminal tab. Forwarded verbatim to the host's create. */ launchCommand?: string; + /** + * Main-owned executable override for transports such as an SSH-backed + * terminal. Never populated from renderer input; the renderer controls only + * dimensions and the allowlisted agent launch request above. + */ + executable?: { + file: string; + args: string[]; + }; } interface TerminalAddressedRequest { @@ -530,6 +539,7 @@ export function createTerminalManager(deps: TerminalManagerDeps): TerminalManage cols: req.cols, rows: req.rows, launchCommand: req.launchCommand, + ...(req.executable ? { executable: req.executable.file, args: req.executable.args } : {}), }); return { ok: true, ptyId }; }, diff --git a/packages/desktop/src/main/terminal-window-registry.test.ts b/packages/desktop/src/main/terminal-window-registry.test.ts index b38babe10..20dbf93f5 100644 --- a/packages/desktop/src/main/terminal-window-registry.test.ts +++ b/packages/desktop/src/main/terminal-window-registry.test.ts @@ -1,8 +1,12 @@ -import { afterEach, describe, expect, test } from 'bun:test'; +import { afterEach, describe, expect, spyOn, test } from 'bun:test'; import { + closeTerminalWindowsForProject, getTerminalWindowContext, + hasLocalProjectAuthority, + isPtyWindowAuthorityCurrent, registerTerminalWindow, resolvePtyProjectRoot, + resolveWindowProjectAuthority, unregisterTerminalWindow, } from './terminal-window-registry.ts'; @@ -10,19 +14,35 @@ import { // leak across cases. const WIN_A = 90_001; const WIN_B = 90_002; +const WIN_C = 90_003; +const NOOP_LIFECYCLE = { window: {}, reapPtys: () => {} }; + +const REMOTE = { + kind: 'ssh' as const, + machineId: 'machine-1', + machineName: 'Build host', + path: '/srv/project', + platform: 'linux', + pathSeparator: '/' as const, +}; afterEach(() => { unregisterTerminalWindow(WIN_A); unregisterTerminalWindow(WIN_B); + unregisterTerminalWindow(WIN_C); }); describe('terminal-window registry', () => { test('round-trips a registered window context by windowId', () => { - registerTerminalWindow(WIN_A, { - projectRoot: '/Users/me/project', - collabUrl: 'ws://localhost:5200/collab', - apiOrigin: 'http://localhost:5200', - }); + registerTerminalWindow( + WIN_A, + { + projectRoot: '/Users/me/project', + collabUrl: 'ws://localhost:5200/collab', + apiOrigin: 'http://localhost:5200', + }, + NOOP_LIFECYCLE, + ); expect(getTerminalWindowContext(WIN_A)).toEqual({ projectRoot: '/Users/me/project', collabUrl: 'ws://localhost:5200/collab', @@ -35,10 +55,81 @@ describe('terminal-window registry', () => { }); test('unregister removes the entry', () => { - registerTerminalWindow(WIN_A, { projectRoot: '/Users/me/project' }); + registerTerminalWindow(WIN_A, { projectRoot: '/Users/me/project' }, NOOP_LIFECYCLE); unregisterTerminalWindow(WIN_A); expect(getTerminalWindowContext(WIN_A)).toBeUndefined(); }); + + test('closing a remote project closes only matching remote terminal windows', () => { + let matchingCloses = 0; + let localCloses = 0; + let otherRemoteCloses = 0; + registerTerminalWindow( + WIN_A, + { projectRoot: 'ssh://machine-1/srv/project', remote: REMOTE }, + { window: { close: () => matchingCloses++ }, reapPtys: () => {} }, + ); + registerTerminalWindow( + WIN_B, + { projectRoot: 'ssh://machine-1/srv/project' }, + { window: { close: () => localCloses++ }, reapPtys: () => {} }, + ); + registerTerminalWindow( + WIN_C, + { + projectRoot: 'ssh://machine-1/srv/other', + remote: { ...REMOTE, path: '/srv/other' }, + }, + { window: { close: () => otherRemoteCloses++ }, reapPtys: () => {} }, + ); + + expect(closeTerminalWindowsForProject('ssh://machine-1/srv/project')).toBe(1); + expect(matchingCloses).toBe(1); + expect(localCloses).toBe(0); + expect(otherRemoteCloses).toBe(0); + }); + + test('closing a remote project prunes an already-destroyed terminal window', () => { + registerTerminalWindow( + WIN_A, + { projectRoot: 'ssh://machine-1/srv/project', remote: REMOTE }, + { window: { close: () => {}, isDestroyed: () => true }, reapPtys: () => {} }, + ); + + expect(closeTerminalWindowsForProject('ssh://machine-1/srv/project')).toBe(0); + expect(getTerminalWindowContext(WIN_A)).toBeUndefined(); + }); + + test('reaps PTYs even when native destroy and close both fail', () => { + let reaps = 0; + const warn = spyOn(console, 'warn').mockImplementation(() => {}); + try { + registerTerminalWindow( + WIN_A, + { projectRoot: 'ssh://machine-1/srv/project', remote: REMOTE }, + { + window: { + destroy: () => { + throw new Error('destroy failed'); + }, + close: () => { + throw new Error('close failed'); + }, + }, + reapPtys: () => reaps++, + }, + ); + + expect(closeTerminalWindowsForProject('ssh://machine-1/srv/project')).toBe(0); + expect(reaps).toBe(1); + expect(getTerminalWindowContext(WIN_A)).toBeUndefined(); + expect(warn).toHaveBeenCalledTimes(2); + expect(warn.mock.calls[0]?.[0]).toContain('remote-terminal-window-destroy-failed'); + expect(warn.mock.calls[1]?.[0]).toContain('remote-terminal-window-close-failed'); + } finally { + warn.mockRestore(); + } + }); }); describe('resolvePtyProjectRoot', () => { @@ -78,3 +169,136 @@ describe('resolvePtyProjectRoot', () => { expect(root).toBeNull(); }); }); + +describe('isPtyWindowAuthorityCurrent', () => { + test('accepts the exact live editor authority captured before an await', () => { + const editorContext = { projectPath: '/Users/me/project' }; + expect( + isPtyWindowAuthorityCurrent({ + sameWindow: true, + windowDestroyed: false, + senderDestroyed: false, + capturedEditorContext: editorContext, + liveEditorContext: editorContext, + capturedTerminalWindow: undefined, + liveTerminalWindow: undefined, + }), + ).toBe(true); + }); + + test('refuses a replaced editor or revoked standalone terminal authority', () => { + const capturedEditor = { projectPath: '/Users/me/project' }; + const terminal = { projectRoot: '/Users/me/project' }; + expect( + isPtyWindowAuthorityCurrent({ + sameWindow: true, + windowDestroyed: false, + senderDestroyed: false, + capturedEditorContext: capturedEditor, + liveEditorContext: { projectPath: '/Users/me/project' }, + capturedTerminalWindow: undefined, + liveTerminalWindow: undefined, + }), + ).toBe(false); + expect( + isPtyWindowAuthorityCurrent({ + sameWindow: true, + windowDestroyed: false, + senderDestroyed: false, + capturedEditorContext: null, + liveEditorContext: null, + capturedTerminalWindow: terminal, + liveTerminalWindow: undefined, + }), + ).toBe(false); + }); + + test('refuses a destroyed or no-longer-matching native window', () => { + const editorContext = { projectPath: '/Users/me/project' }; + for (const state of [ + { sameWindow: false, windowDestroyed: false, senderDestroyed: false }, + { sameWindow: true, windowDestroyed: true, senderDestroyed: false }, + { sameWindow: true, windowDestroyed: false, senderDestroyed: true }, + ]) { + expect( + isPtyWindowAuthorityCurrent({ + ...state, + capturedEditorContext: editorContext, + liveEditorContext: editorContext, + capturedTerminalWindow: undefined, + liveTerminalWindow: undefined, + }), + ).toBe(false); + } + }); +}); + +describe('resolveWindowProjectAuthority', () => { + test('prefers the editor-owned local project', () => { + expect( + resolveWindowProjectAuthority({ + editorProjectPath: '/Users/me/editor-project', + editorRemote: undefined, + terminalWindow: { projectRoot: '/Users/me/terminal-project', remote: REMOTE }, + }), + ).toEqual({ projectPath: '/Users/me/editor-project', remote: false }); + }); + + test('classifies a standalone remote terminal without treating its opaque key as local', () => { + expect( + resolveWindowProjectAuthority({ + editorProjectPath: undefined, + editorRemote: undefined, + terminalWindow: { projectRoot: 'ssh:machine-1:%2Fsrv%2Fproject', remote: REMOTE }, + }), + ).toEqual({ projectPath: 'ssh:machine-1:%2Fsrv%2Fproject', remote: true }); + }); + + test('returns local authority for a project-bound standalone terminal', () => { + expect( + resolveWindowProjectAuthority({ + editorProjectPath: undefined, + editorRemote: undefined, + terminalWindow: { projectRoot: '/Users/me/project' }, + }), + ).toEqual({ projectPath: '/Users/me/project', remote: false }); + }); + + test('returns null for project-less and unregistered windows', () => { + expect( + resolveWindowProjectAuthority({ + editorProjectPath: undefined, + editorRemote: undefined, + terminalWindow: { projectRoot: null }, + }), + ).toBeNull(); + expect( + resolveWindowProjectAuthority({ + editorProjectPath: undefined, + editorRemote: undefined, + terminalWindow: undefined, + }), + ).toBeNull(); + }); +}); + +describe('hasLocalProjectAuthority', () => { + test('requires exact sender-owned path equality', () => { + const authority = { projectPath: '/Users/me/project', remote: false } as const; + expect(hasLocalProjectAuthority(authority, '/Users/me/project')).toBe(true); + expect(hasLocalProjectAuthority(authority, '/Users/me/other')).toBe(false); + }); + + test('refuses remote, project-less, and malformed requests', () => { + expect( + hasLocalProjectAuthority( + { projectPath: 'ssh:machine-1:%2Fsrv%2Fproject', remote: true }, + 'ssh:machine-1:%2Fsrv%2Fproject', + ), + ).toBe(false); + expect(hasLocalProjectAuthority(null, '/Users/me/project')).toBe(false); + expect( + hasLocalProjectAuthority({ projectPath: '/Users/me/project', remote: false }, undefined), + ).toBe(false); + }); +}); diff --git a/packages/desktop/src/main/terminal-window-registry.ts b/packages/desktop/src/main/terminal-window-registry.ts index ad35495b0..722bbfc19 100644 --- a/packages/desktop/src/main/terminal-window-registry.ts +++ b/packages/desktop/src/main/terminal-window-registry.ts @@ -9,29 +9,135 @@ * consent path from this registry instead. */ +import type { RemoteProjectInfo } from '@inkeep/open-knowledge-core'; + export interface TerminalWindowContext { /** Inherited project root, or null when the window was launched project-less. */ readonly projectRoot: string | null; + /** Stable display name used when chaining another terminal window. */ + readonly projectName?: string; /** Inherited collab server URL (attach-mode) when the project's server is running. */ readonly collabUrl?: string; /** Inherited API origin (attach-mode). */ readonly apiOrigin?: string; + /** SSH metadata when this terminal is attached to a remote project. */ + readonly remote?: RemoteProjectInfo; +} + +/** + * Minimal native-window surface retained by the registry. Keeping this + * structural avoids importing Electron (and keeps the registry unit-testable), + * while still letting the remote-session owner close attached terminal + * windows before its SSH tunnel is released. + */ +interface TerminalWindowHandle { + close?(): void; + destroy?(): void; + isDestroyed?(): boolean; +} + +interface TerminalWindowLifecycle { + readonly window: TerminalWindowHandle; + readonly reapPtys: () => void; +} + +interface RegisteredTerminalWindow { + readonly context: TerminalWindowContext; + readonly lifecycle: TerminalWindowLifecycle; } -const terminalWindows = new Map(); +const terminalWindows = new Map(); + +function warnTeardownFailure(event: string, windowId: number, error: unknown): void { + console.warn( + JSON.stringify({ + event, + windowId, + message: error instanceof Error ? error.message : String(error), + }), + ); +} -export function registerTerminalWindow(windowId: number, context: TerminalWindowContext): void { - terminalWindows.set(windowId, context); +export function registerTerminalWindow( + windowId: number, + context: TerminalWindowContext, + lifecycle: TerminalWindowLifecycle, +): void { + terminalWindows.set(windowId, { context, lifecycle }); } export function getTerminalWindowContext(windowId: number): TerminalWindowContext | undefined { - return terminalWindows.get(windowId); + return terminalWindows.get(windowId)?.context; } export function unregisterTerminalWindow(windowId: number): void { terminalWindows.delete(windowId); } +/** + * Close every standalone terminal attached to one SSH-backed project. + * + * Remote terminal renderers inherit the editor's loopback HTTP/WebSocket + * endpoints, so they cannot remain usable after that editor releases its SSH + * tunnel. Local and project-less terminals are intentionally left alone: only + * entries carrying remote metadata and the exact opaque project key match. + * + * Entries are revoked before touching the native window, then destroyed when + * possible so renderer `beforeunload` cannot veto teardown. Returns the number + * of live native windows for which destroy/close was requested. A stale + * destroyed entry is pruned defensively. + */ +export function closeTerminalWindowsForProject(projectRoot: string): number { + let closeRequests = 0; + for (const [windowId, registered] of terminalWindows) { + if (registered.context.remote === undefined || registered.context.projectRoot !== projectRoot) { + continue; + } + + // Revoke the main-process project/remote authority synchronously. Even if + // later cleanup fails, this renderer cannot create another PTY or run a + // fresh remote probe while the editor releases its session. + terminalWindows.delete(windowId); + const { window, reapPtys } = registered.lifecycle; + // Reap the PTY host before touching the native window. `destroy()` normally + // emits `closed`, whose lifecycle listener reaps again, but native teardown + // can throw. Revoking the shell here makes cleanup deterministic even then; + // TerminalManager.killForWindow is idempotent. + try { + reapPtys(); + } catch (error) { + warnTeardownFailure('remote-terminal-pty-reap-failed', windowId, error); + } + if (window.isDestroyed?.() === true) { + continue; + } + if (window.destroy) { + try { + window.destroy(); + closeRequests += 1; + } catch (error) { + warnTeardownFailure('remote-terminal-window-destroy-failed', windowId, error); + // Fall back to a graceful close if a force-destroy unexpectedly fails. + // The registry entry is already gone and its PTYs have been reaped. + try { + window.close?.(); + if (window.close) closeRequests += 1; + } catch (closeError) { + warnTeardownFailure('remote-terminal-window-close-failed', windowId, closeError); + } + } + } else if (window.close) { + try { + window.close(); + closeRequests += 1; + } catch (error) { + warnTeardownFailure('remote-terminal-window-close-failed', windowId, error); + } + } + } + return closeRequests; +} + /** * Resolve the cwd for an `ok:pty:create` call. * @@ -51,3 +157,70 @@ export function resolvePtyProjectRoot(args: { if (args.terminalWindow) return args.terminalWindow.projectRoot ?? args.homedir; return null; } + +/** + * Revalidate the window-owned authority captured before an asynchronous PTY + * consent probe. Object identity is intentional: a replacement editor or a + * revoked standalone-terminal registration must not inherit the completed + * probe and create a shell for the old window. + */ +export function isPtyWindowAuthorityCurrent(args: { + readonly sameWindow: boolean; + readonly windowDestroyed: boolean; + readonly senderDestroyed: boolean; + readonly capturedEditorContext: unknown | null; + readonly liveEditorContext: unknown | null; + readonly capturedTerminalWindow: TerminalWindowContext | undefined; + readonly liveTerminalWindow: TerminalWindowContext | undefined; +}): boolean { + if (!args.sameWindow || args.windowDestroyed || args.senderDestroyed) return false; + if (args.capturedEditorContext !== null) { + return args.liveEditorContext === args.capturedEditorContext; + } + return ( + args.capturedTerminalWindow !== undefined && + args.liveTerminalWindow === args.capturedTerminalWindow + ); +} + +/** Sender-owned project authority for local filesystem/process operations. */ +export interface WindowProjectAuthority { + readonly projectPath: string; + readonly remote: boolean; +} + +/** True only when the sender owns this exact local project path. */ +export function hasLocalProjectAuthority( + authority: WindowProjectAuthority | null, + requestedProjectPath: unknown, +): boolean { + return ( + typeof requestedProjectPath === 'string' && + authority !== null && + !authority.remote && + authority.projectPath === requestedProjectPath + ); +} + +/** + * Resolve project identity consistently for editor and standalone terminal + * windows. Editor context wins when both are present. A project-less terminal + * and the Navigator have no project authority; an SSH-backed context remains + * identifiable even though its opaque project key is not a local path. + */ +export function resolveWindowProjectAuthority(args: { + readonly editorProjectPath: string | null | undefined; + readonly editorRemote: RemoteProjectInfo | undefined; + readonly terminalWindow: TerminalWindowContext | undefined; +}): WindowProjectAuthority | null { + if (args.editorProjectPath) { + return { projectPath: args.editorProjectPath, remote: args.editorRemote !== undefined }; + } + if (args.terminalWindow?.projectRoot) { + return { + projectPath: args.terminalWindow.projectRoot, + remote: args.terminalWindow.remote !== undefined, + }; + } + return null; +} diff --git a/packages/desktop/src/main/terminal-window.test.ts b/packages/desktop/src/main/terminal-window.test.ts index 7d2919159..ad8561cf2 100644 --- a/packages/desktop/src/main/terminal-window.test.ts +++ b/packages/desktop/src/main/terminal-window.test.ts @@ -7,7 +7,11 @@ import { type TerminalBrowserWindow, type TerminalWindowProject, } from './terminal-window.ts'; -import { getTerminalWindowContext, unregisterTerminalWindow } from './terminal-window-registry.ts'; +import { + closeTerminalWindowsForProject, + getTerminalWindowContext, + unregisterTerminalWindow, +} from './terminal-window-registry.ts'; const PROJECT: TerminalWindowProject = { projectPath: '/Users/me/project', @@ -16,6 +20,21 @@ const PROJECT: TerminalWindowProject = { apiOrigin: 'http://localhost:5200', }; +const REMOTE_PROJECT: TerminalWindowProject = { + projectPath: 'ssh://machine-1/srv/project', + projectName: 'project', + collabUrl: 'ws://127.0.0.1:5200/collab', + apiOrigin: 'http://127.0.0.1:5200', + remote: { + kind: 'ssh', + machineId: 'machine-1', + machineName: 'Build host', + path: '/srv/project', + platform: 'linux', + pathSeparator: '/', + }, +}; + /** A fake window exposing only what the factory touches; `closed` handlers are * captured so a test can fire the lifecycle event. */ function makeFakeWindow(id: number) { @@ -26,6 +45,8 @@ function makeFakeWindow(id: number) { if (event === 'closed') closedHandlers.push(cb); }, once: () => {}, + close: mock(() => {}), + destroy: mock(() => {}), loadFile: mock(async () => {}), loadURL: mock(async () => {}), webContents: { send: () => {}, once: () => {} }, @@ -96,11 +117,20 @@ describe('createTerminalWindow', () => { expect(h.createWindow.mock.calls[0]?.[0]?.title).toBe('Open Knowledge Terminal — project'); }); + test('includes the SSH machine in a remote terminal window title', () => { + const h = makeDeps({ id: 70_001, project: REMOTE_PROJECT }); + createTerminalWindow(h.deps); + expect(h.createWindow.mock.calls[0]?.[0]?.title).toBe( + 'Open Knowledge Terminal — project • Build host', + ); + }); + test('records the window in the terminalWindows registry with its project root', () => { const h = makeDeps({ id: 70_001, project: PROJECT }); createTerminalWindow(h.deps); expect(getTerminalWindowContext(70_001)).toEqual({ projectRoot: '/Users/me/project', + projectName: 'project', collabUrl: 'ws://localhost:5200/collab', apiOrigin: 'http://localhost:5200', }); @@ -154,6 +184,17 @@ describe('createTerminalWindow', () => { b.fake.fireClosed(); }); + test('registers the native handle so remote-session teardown can close the terminal', () => { + const h = makeDeps({ id: 70_001, project: REMOTE_PROJECT }); + createTerminalWindow(h.deps); + + expect(closeTerminalWindowsForProject(REMOTE_PROJECT.projectPath)).toBe(1); + expect(h.killForWindow).toHaveBeenCalledWith(70_001); + expect(h.fake.window.destroy).toHaveBeenCalledTimes(1); + expect(h.fake.window.close).not.toHaveBeenCalled(); + expect(getTerminalWindowContext(70_001)).toBeUndefined(); + }); + test('uses loadURL with the dev URL when provided, else loadFile', () => { const dev = makeDeps({ id: 70_001, project: PROJECT, rendererDevUrl: 'http://localhost:5173' }); createTerminalWindow(dev.deps); @@ -219,6 +260,35 @@ describe('resolveTerminalWindowProject', () => { }); }); + test('a remote editor keeps the collab URL on the IPv4 loopback tunnel', () => { + const remote = { + kind: 'ssh' as const, + machineId: 'machine-1', + machineName: 'Build host', + path: '/srv/project', + platform: 'linux', + pathSeparator: '/' as const, + }; + expect( + resolveTerminalWindowProject({ + editor: { + projectPath: 'ssh://machine-1/srv/project', + projectName: 'project', + port: 5200, + apiOrigin: 'http://127.0.0.1:5200', + remote, + }, + terminal: undefined, + }), + ).toEqual({ + projectPath: 'ssh://machine-1/srv/project', + projectName: 'project', + collabUrl: 'ws://127.0.0.1:5200/collab', + apiOrigin: 'http://127.0.0.1:5200', + remote, + }); + }); + test('a focused terminal window inherits its registry context (chaining)', () => { expect( resolveTerminalWindowProject({ @@ -237,6 +307,27 @@ describe('resolveTerminalWindowProject', () => { }); }); + test('remote terminal chaining preserves the display name instead of exposing the opaque key', () => { + expect( + resolveTerminalWindowProject({ + editor: null, + terminal: { + projectRoot: 'ssh:machine-1:%2Fsrv%2Fproject', + projectName: 'project', + collabUrl: 'ws://127.0.0.1:5300/collab', + apiOrigin: 'http://127.0.0.1:5300', + remote: REMOTE_PROJECT.remote, + }, + }), + ).toEqual({ + projectPath: 'ssh:machine-1:%2Fsrv%2Fproject', + projectName: 'project', + collabUrl: 'ws://127.0.0.1:5300/collab', + apiOrigin: 'http://127.0.0.1:5300', + remote: REMOTE_PROJECT.remote, + }); + }); + test('chaining from a terminal window with no collab/api fields falls back to empty strings', () => { // The registry's collabUrl/apiOrigin are optional; the resolver's `?? ''` // fallbacks must keep argv as `--ok-collab-url=` (empty) rather than letting diff --git a/packages/desktop/src/main/terminal-window.ts b/packages/desktop/src/main/terminal-window.ts index 941721663..1e0346023 100644 --- a/packages/desktop/src/main/terminal-window.ts +++ b/packages/desktop/src/main/terminal-window.ts @@ -17,6 +17,7 @@ */ import { basename } from 'node:path'; +import type { RemoteProjectInfo } from '@inkeep/open-knowledge-core'; import type { ShowGateRegistry } from './show-gate.ts'; import { type TerminalReaper, wireWindowTerminalReap } from './terminal-lifecycle.ts'; import { @@ -37,6 +38,7 @@ export interface TerminalWindowProject { readonly projectName: string; readonly collabUrl: string; readonly apiOrigin: string; + readonly remote?: RemoteProjectInfo; } interface CreateTerminalWindowDeps { @@ -60,7 +62,10 @@ const GENERIC_TITLE = 'Open Knowledge Terminal'; export function createTerminalWindow(deps: CreateTerminalWindowDeps): TerminalBrowserWindow { const { project } = deps; - const title = project ? `${GENERIC_TITLE} — ${project.projectName}` : GENERIC_TITLE; + const projectTitle = project?.remote + ? `${project.projectName} • ${project.remote.machineName}` + : project?.projectName; + const title = projectTitle ? `${GENERIC_TITLE} — ${projectTitle}` : GENERIC_TITLE; const window = deps.createWindow({ additionalArguments: [ '--ok-mode=terminal', @@ -73,20 +78,39 @@ export function createTerminalWindow(deps: CreateTerminalWindowDeps): TerminalBr `--ok-api-origin=${project?.apiOrigin ?? ''}`, `--ok-project-path=${project?.projectPath ?? ''}`, `--ok-project-name=${project?.projectName ?? GENERIC_TITLE}`, + ...(project?.remote + ? [ + `--ok-remote-machine-id=${project.remote.machineId}`, + `--ok-remote-machine-name=${project.remote.machineName}`, + `--ok-remote-path=${project.remote.path}`, + `--ok-remote-platform=${project.remote.platform}`, + `--ok-remote-path-separator=${project.remote.pathSeparator}`, + ] + : []), ], title, }); + const windowId = window.id; // Record the window's cwd + attach context BEFORE the renderer loads, so the // first `ok:pty:create` resolves a shell. Terminal windows are deliberately // absent from `windowsByPath` (one-per-project, focus-existing), so this is // their only cwd/consent source. projectRoot is null when project-less; the // create handler falls back to homedir(). - registerTerminalWindow(window.id, { - projectRoot: project?.projectPath ?? null, - collabUrl: project?.collabUrl, - apiOrigin: project?.apiOrigin, - }); + registerTerminalWindow( + windowId, + { + projectRoot: project?.projectPath ?? null, + projectName: project?.projectName, + collabUrl: project?.collabUrl, + apiOrigin: project?.apiOrigin, + remote: project?.remote, + }, + { + window, + reapPtys: () => deps.terminalReaper.killForWindow(windowId), + }, + ); // Closing the window reaps its PTY host (no orphan shells) and drops its // registry entry. The reap wiring captures the id eagerly (the window is @@ -95,7 +119,7 @@ export function createTerminalWindow(deps: CreateTerminalWindowDeps): TerminalBr const disposeShowGate = deps.showGate.register(window, { kind: 'terminal' }); window.on('closed', () => { disposeShowGate(); - unregisterTerminalWindow(window.id); + unregisterTerminalWindow(windowId); }); // Surface load failures with a grep-able structured warn (mirrors the @@ -110,7 +134,7 @@ export function createTerminalWindow(deps: CreateTerminalWindowDeps): TerminalBr event: 'terminal-load-failed', // Terminal windows are multi-instance (unlike the single Navigator), so // attribute the failure to the specific window. - windowId: window.id, + windowId, target: deps.rendererDevUrl ?? deps.rendererEntryPath, message: err instanceof Error ? err.message : String(err), }), @@ -127,6 +151,7 @@ interface EditorProjectContext { readonly projectName: string; readonly port: number; readonly apiOrigin: string; + readonly remote?: RemoteProjectInfo; } /** @@ -146,16 +171,20 @@ export function resolveTerminalWindowProject(args: { return { projectPath: args.editor.projectPath, projectName: args.editor.projectName, - collabUrl: `ws://localhost:${args.editor.port}/collab`, + collabUrl: `ws://${args.editor.remote ? '127.0.0.1' : 'localhost'}:${args.editor.port}/collab`, apiOrigin: args.editor.apiOrigin, + remote: args.editor.remote, }; } if (args.terminal?.projectRoot) { + const remoteProjectName = args.terminal.remote?.path.split(/[/\\]/).filter(Boolean).at(-1); return { projectPath: args.terminal.projectRoot, - projectName: basename(args.terminal.projectRoot), + projectName: + args.terminal.projectName ?? remoteProjectName ?? basename(args.terminal.projectRoot), collabUrl: args.terminal.collabUrl ?? '', apiOrigin: args.terminal.apiOrigin ?? '', + remote: args.terminal.remote, }; } return null; diff --git a/packages/desktop/src/main/window-manager.ts b/packages/desktop/src/main/window-manager.ts index 14891d3a4..d1477a669 100644 --- a/packages/desktop/src/main/window-manager.ts +++ b/packages/desktop/src/main/window-manager.ts @@ -27,15 +27,18 @@ import { readFileSync, realpathSync } from 'node:fs'; import { basename, dirname, join, resolve } from 'node:path'; +import type { RemoteProjectInfo } from '@inkeep/open-knowledge-core'; import { DEFAULT_SIGTERM_GRACE_MS, DEFAULT_SIGTERM_POLL_MS, + isRemoteProjectKey, SPAWN_ERROR_LOG, } from '@inkeep/open-knowledge-core'; import type { KeepaliveHandle } from '@inkeep/open-knowledge-core/keepalive'; import { getLocalDir } from '@inkeep/open-knowledge-server'; import type { OkServerRestartOutcome } from '../shared/bridge-contract.ts'; import { registerPendingDelivery } from '../shared/ipc-send.ts'; +import { attachRemoteEditorTrustBoundary } from './remote-editor-trust-boundary.ts'; import type { ShowGateRegistry } from './show-gate.ts'; import type { ShareDeepLinkBranchSwitchPayload } from './url-scheme.ts'; import { classifyServerVersion } from './version-drift.ts'; @@ -278,6 +281,11 @@ interface ProjectContext { * `false`, closing the window leaves the sibling-owned server running. */ ownsServer: boolean; + /** SSH metadata + tunnel cleanup for a remote editor window. */ + remote?: RemoteProjectInfo; + /** Close only the current session's superseded local SSH port forward. */ + retireRemoteTunnel?: () => void; + closeRemoteSession?: () => void; /** * No-project ephemeral single-file session teardown state. Present only on * windows created by `createEphemeralWindow`. Unlike a normal detached @@ -390,6 +398,19 @@ interface CreateProjectWindowOpts { freshlyCreated?: boolean; } +export interface CreateRemoteProjectWindowOpts { + /** Opaque machine-scoped key, also used by recents and session state. */ + projectKey: string; + projectName: string; + remote: RemoteProjectInfo; + port: number; + apiOrigin: string; + collabUrl: string; + /** Retires this session's tunnel without releasing remote server ownership. */ + retireTunnel?: () => void; + closeSession: () => void; +} + /** Test-injectable side-effect surface (Electron + node:fs primitives). */ export interface WindowManagerDeps { /** `electron.BrowserWindow` constructor (subsetted). */ @@ -529,6 +550,13 @@ export interface WindowManagerDeps { /** electron-vite dev-server URL (`process.env.ELECTRON_RENDERER_URL`). When present, * main uses `loadURL` for HMR; otherwise falls back to `loadFile(rendererEntryPath)`. */ rendererDevUrl?: string | null; + /** + * Open a public web/mail URL outside an SSH-backed editor window. The remote + * editor trust boundary applies its own narrow URL policy before invoking + * this callback. Production wires the shared shell.openExternal allowlist; + * Required so a remote editor never silently drops a user-approved link. + */ + openExternal(url: string): Promise; /** * App version (`app.getVersion()`), threaded through to the renderer's preload * via `--ok-app-version=` in `additionalArguments`. Without this, the preload @@ -808,6 +836,7 @@ export class WindowManager { * paths don't throw past the call site. */ private canonicalizeKey(projectPath: string): string { + if (isRemoteProjectKey(projectPath)) return projectPath; const absolute = resolve(projectPath); const rp = this.deps.realpathSync ?? realpathSync; try { @@ -1785,6 +1814,251 @@ export class WindowManager { return context; } + /** + * Create an editor window against a server reached through a desktop-owned + * SSH loopback tunnel. The renderer receives the same HTTP/WS transport as a + * local project; only the project identity and remote metadata differ. + */ + async createRemoteProjectWindow(opts: CreateRemoteProjectWindowOpts): Promise { + const canonicalKey = opts.projectKey; + let sessionClosed = false; + const closeRemoteSession = (): void => { + if (sessionClosed) return; + sessionClosed = true; + try { + opts.closeSession(); + } catch (err) { + this.deps.log?.warn( + { + event: 'desktop-remote-session-close-failed', + err: err instanceof Error ? err.message : String(err), + }, + '[window-manager] remote session cleanup failed', + ); + } + }; + const existing = this.windowsByPath.get(canonicalKey); + if (existing && existing.window.isDestroyed?.() !== true) { + // The caller already opened a fresh SSH session. Do not leak it when the + // one-window-per-project dedup focuses the existing window instead. + closeRemoteSession(); + this.bringToFront(existing.window); + return existing; + } + if (existing) this.windowsByPath.delete(canonicalKey); + + let window: BrowserWindowLike | null = null; + let disposeShowGate: (() => void) | null = null; + let context: ProjectContext | null = null; + try { + window = this.deps.createWindow({ + additionalArguments: [ + `--ok-collab-url=${opts.collabUrl}`, + `--ok-api-origin=${opts.apiOrigin}`, + `--ok-project-path=${opts.projectKey}`, + `--ok-project-name=${opts.projectName}`, + '--ok-mode=editor', + `--ok-app-version=${this.deps.appVersion}`, + `--ok-remote-machine-id=${opts.remote.machineId}`, + `--ok-remote-machine-name=${opts.remote.machineName}`, + `--ok-remote-path=${opts.remote.path}`, + `--ok-remote-platform=${opts.remote.platform}`, + `--ok-remote-path-separator=${opts.remote.pathSeparator}`, + ], + title: formatEditorTitle(`${opts.projectName} • ${opts.remote.machineName}`), + }); + // Install before any renderer load. The tunneled project server can serve + // author-controlled HTML, so it must never replace this privileged top + // frame and inherit the preload bridge. + attachRemoteEditorTrustBoundary( + window.webContents, + { + rendererEntryPath: this.deps.rendererEntryPath, + rendererDevUrl: this.deps.rendererDevUrl, + }, + { + apiOrigin: opts.apiOrigin, + openExternal: this.deps.openExternal, + log: (event) => { + this.deps.log?.warn( + { event: 'desktop-remote-editor-navigation-blocked', ...event.data }, + `[window-manager] ${event.message}`, + ); + }, + }, + ); + disposeShowGate = this.deps.showGate.register(window, { kind: 'editor' }); + + // Reserve the key before renderer loading begins. Two concurrent opens can + // otherwise both pass the dedup check, create separate tunnels/windows, + // and leave the first session unreachable when the second map write wins. + context = { + projectPath: opts.projectKey, + canonicalKey, + projectName: opts.projectName, + port: opts.port, + apiOrigin: opts.apiOrigin, + window, + utility: null, + ownsServer: false, + remote: opts.remote, + retireRemoteTunnel: opts.retireTunnel, + closeRemoteSession, + }; + const ownedContext = context; + this.windowsByPath.set(canonicalKey, ownedContext); + window.on('closed', () => { + disposeShowGate?.(); + if (this.windowsByPath.get(canonicalKey) === ownedContext) { + this.windowsByPath.delete(canonicalKey); + } + // Resolve cleanup through the context instead of capturing the initial + // function. A transactional reconnect transfers the prior owner cleanup + // onto the replacement context before it closes the old window. + ownedContext.closeRemoteSession?.(); + }); + + if (this.deps.rendererDevUrl) { + await window.loadURL(this.deps.rendererDevUrl); + } else { + await window.loadFile(this.deps.rendererEntryPath); + } + if (window.isDestroyed?.() === true) { + throw new Error('Remote project window closed while loading.'); + } + return ownedContext; + } catch (error) { + disposeShowGate?.(); + if (context && this.windowsByPath.get(canonicalKey) === context) { + this.windowsByPath.delete(canonicalKey); + } + closeRemoteSession(); + // A rejected load does not guarantee Electron destroyed the native + // window. Close the failed candidate so it cannot survive off-map as an + // untracked editor with a dead tunnel. + if (window && window.isDestroyed?.() !== true) { + try { + window.close?.(); + } catch (closeError) { + this.deps.log?.warn( + { + event: 'desktop-remote-window-close-failed', + err: closeError instanceof Error ? closeError.message : String(closeError), + }, + '[window-manager] failed remote window could not be closed', + ); + } + } + throw error; + } + } + + /** + * Transactionally replace an SSH-backed project window with a fresh + * tunnel/window. The originating window stays alive while the replacement + * loads. A failure closes only the fresh session and restores the old + * context; the old window closes only after the replacement is committed. + * + * The caller revokes the originating SSH transport before entering this + * transaction. This method keeps only the editor window alive while the + * replacement loads. + */ + async replaceRemoteProjectWindow( + opts: CreateRemoteProjectWindowOpts, + originatingWindow: BrowserWindowLike, + ): Promise { + let replacementSessionClosed = false; + const closeReplacementSession = (): void => { + if (replacementSessionClosed) return; + replacementSessionClosed = true; + try { + opts.closeSession(); + } catch (err) { + this.deps.log?.warn( + { + event: 'desktop-remote-session-close-failed', + err: err instanceof Error ? err.message : String(err), + }, + '[window-manager] replacement remote session cleanup failed', + ); + } + }; + return this.replaceRemoteProjectWindowTransaction( + { ...opts, closeSession: closeReplacementSession }, + originatingWindow, + ); + } + + private async replaceRemoteProjectWindowTransaction( + opts: CreateRemoteProjectWindowOpts, + expectedOriginatingWindow: BrowserWindowLike, + ): Promise { + const canonicalKey = opts.projectKey; + const originating = this.windowsByPath.get(canonicalKey); + if ( + !originating || + originating.window !== expectedOriginatingWindow || + originating.window.isDestroyed?.() === true + ) { + opts.closeSession(); + throw new Error('Remote project window closed before it could be replaced.'); + } + if (!originating.remote) { + opts.closeSession(); + throw new Error('Cannot replace a local project window with a remote session.'); + } + if (originating.closeRemoteSession || originating.retireRemoteTunnel) { + opts.closeSession(); + throw new Error('Release the originating remote session before replacing its window.'); + } + + // Keep the old native window alive, but let createRemoteProjectWindow + // reserve the canonical key for the fresh renderer during its load. Its + // IPC calls therefore resolve to the replacement context, just like an + // ordinary remote open. Cleanup is already disarmed by the reconnect + // caller before it starts the exclusively owned replacement server. + this.windowsByPath.delete(canonicalKey); + + let replacement: ProjectContext | undefined; + try { + replacement = await this.createRemoteProjectWindow(opts); + if (replacement.window.isDestroyed?.() === true) { + throw new Error('Remote replacement window closed while loading.'); + } + if (originating.window.isDestroyed?.() === true) { + throw new Error('Remote project window closed while it was being replaced.'); + } + + await this.closeAndAwait(originating.window); + if (replacement.window.isDestroyed?.() === true) { + throw new Error('Remote replacement window closed before the replacement committed.'); + } + return replacement; + } catch (error) { + if (replacement) { + await this.closeAndAwait(replacement.window); + if (this.windowsByPath.get(canonicalKey) === replacement) { + this.windowsByPath.delete(canonicalKey); + } + replacement.closeRemoteSession?.(); + } else { + // Covers constructor/show-gate failures before a ProjectContext exists. + opts.closeSession(); + } + + if (originating.window.isDestroyed?.() === true) { + // No live window remains after replacement failure. + } else { + const current = this.windowsByPath.get(canonicalKey); + if (!current || current.window.isDestroyed?.() === true) { + this.windowsByPath.set(canonicalKey, originating); + this.bringToFront(originating.window); + } + } + throw error; + } + } + /** * Open (or focus) an ephemeral single-file editing session for a no-project * file (`ok `). Distinct from `createProjectWindow` in three ways that diff --git a/packages/desktop/src/preload/index.ts b/packages/desktop/src/preload/index.ts index 440e9ceff..255003dbe 100644 --- a/packages/desktop/src/preload/index.ts +++ b/packages/desktop/src/preload/index.ts @@ -20,6 +20,7 @@ */ import type { + RemoteProjectInfo, WorktreeCreateRequest, WorktreeCreateResult, WorktreeListResult, @@ -209,6 +210,39 @@ function readConfigFromArgv(): OkDesktopConfig { const apiOrigin = parseArg('api-origin') ?? ''; const projectPath = parseArg('project-path') ?? ''; const projectName = parseArg('project-name') ?? ''; + const remoteMachineId = parseArg('remote-machine-id'); + const remoteMachineName = parseArg('remote-machine-name'); + const remotePath = parseArg('remote-path'); + const remotePlatform = parseArg('remote-platform'); + const remotePathSeparator = parseArg('remote-path-separator'); + const remoteValues = [ + remoteMachineId, + remoteMachineName, + remotePath, + remotePlatform, + remotePathSeparator, + ]; + const remoteValueCount = remoteValues.filter((value) => value !== undefined).length; + if (remoteValueCount !== 0 && remoteValueCount !== remoteValues.length) { + throw new Error('Remote window arguments are incomplete.'); + } + let remote: RemoteProjectInfo | null = null; + if (remoteValueCount > 0) { + if (remotePlatform !== 'darwin' && remotePlatform !== 'linux') { + throw new Error('Remote platform is not supported.'); + } + if (remotePathSeparator !== '/') { + throw new Error('Remote path separator is not supported.'); + } + remote = { + kind: 'ssh', + machineId: remoteMachineId as string, + machineName: remoteMachineName as string, + path: remotePath as string, + platform: remotePlatform, + pathSeparator: '/', + }; + } const mode = resolveOkDesktopMode(parseArg('mode')); // Present only on ephemeral single-file windows (`ok `); every normal // project window omits the flag and coerces to `false`. @@ -233,6 +267,7 @@ function readConfigFromArgv(): OkDesktopConfig { apiOrigin, projectPath, projectName, + remote, mode, e2eSmoke, singleFile, @@ -416,6 +451,37 @@ const bridge: OkDesktopBridge = { writeText: (text: string) => invoke('ok:clipboard:write-text', text), }, + remote: { + listMachines: () => + invoke('ok:remote:dispatch', { kind: 'list-machines' }) as ReturnType< + OkDesktopBridge['remote']['listMachines'] + >, + saveMachine: (machine) => + invoke('ok:remote:dispatch', { kind: 'save-machine', machine }) as ReturnType< + OkDesktopBridge['remote']['saveMachine'] + >, + removeMachine: (machineId) => + invoke('ok:remote:dispatch', { kind: 'remove-machine', machineId }) as ReturnType< + OkDesktopBridge['remote']['removeMachine'] + >, + testMachine: (machineId) => + invoke('ok:remote:dispatch', { kind: 'test-machine', machineId }) as ReturnType< + OkDesktopBridge['remote']['testMachine'] + >, + listDirectories: ({ machineId, path }) => + invoke('ok:remote:dispatch', { + kind: 'list-directories', + machineId, + path, + }) as ReturnType, + openProject: ({ machineId, path }) => + invoke('ok:remote:dispatch', { + kind: 'open-project', + machineId, + path, + }) as ReturnType, + }, + project: { listRecent: () => invoke('ok:project:list-recent'), removeRecent: (path: string) => invoke('ok:project:remove-recent', path), diff --git a/packages/desktop/src/shared/bridge-contract.ts b/packages/desktop/src/shared/bridge-contract.ts index 80c8972e5..44bf31443 100644 --- a/packages/desktop/src/shared/bridge-contract.ts +++ b/packages/desktop/src/shared/bridge-contract.ts @@ -25,7 +25,12 @@ import type { EditorId, LocalOpOkInitResponse, OkFolderState, + RemoteDirectoryListing, + RemoteProjectInfo, ShareTargetStatusResponse, + SshConnectionTestResult, + SshMachine, + SshMachineDraft, TerminalCli, WorktreeCreateRequest, WorktreeCreateResult, @@ -121,6 +126,8 @@ export interface OkDesktopConfig { readonly apiOrigin: string; readonly projectPath: string; readonly projectName: string; + /** SSH metadata for a remote window; null for local/navigator windows. */ + readonly remote: RemoteProjectInfo | null; readonly mode: OkDesktopMode; /** * `true` only under the Electron smoke suite (main injects `--ok-e2e-smoke=1`). @@ -877,7 +884,8 @@ export type OkServerRestartOutcome = * (fail-open): the terminal is allowed by default, so main refuses only when the * window's project-local `terminal.enabled === false`, preventing a renderer * regression or compromise from spawning a real shell in a project a human has - * explicitly opted out of. + * explicitly opted out of. `remote-unavailable` keeps SSH/helper failures + * distinct from both project authority and the user's consent choice. * * NOTE: structurally mirrored verbatim in `core/src/desktop-bridge.ts` and * `app/src/lib/desktop-bridge-types.ts` — the m1-smoke + bridge-contract-types @@ -885,7 +893,10 @@ export type OkServerRestartOutcome = */ export type OkPtyCreateResult = | { readonly ok: true; readonly ptyId: string } - | { readonly ok: false; readonly reason: 'no-project' | 'not-consented' }; + | { + readonly ok: false; + readonly reason: 'no-project' | 'not-consented' | 'remote-unavailable'; + }; /** * One entry of the reload-rehydration inventory (`terminal.list`) — a ptyId that @@ -945,7 +956,7 @@ export interface OkPtyExit { */ export interface ClaudeReadiness { readonly claude: 'present' | 'not-found' | 'unknown'; - readonly mcp: 'wired' | 'needs-rewire'; + readonly mcp: 'wired' | 'needs-rewire' | 'unknown'; /** True when the project's own `open-knowledge` `.mcp.json` entry is verified * to be OK's canonical managed server (cli `isOwnManagedEntry`), so the docked * terminal may pre-approve it on Claude launch instead of re-showing Claude's @@ -1291,6 +1302,16 @@ export interface OkDesktopBridge { writeText(text: string): Promise; }; + /** Saved-machine management and SSH-backed project opening. */ + remote: { + listMachines(): Promise; + saveMachine(machine: SshMachineDraft): Promise; + removeMachine(machineId: string): Promise; + testMachine(machineId: string): Promise; + listDirectories(request: { machineId: string; path: string }): Promise; + openProject(request: { machineId: string; path: string }): Promise; + }; + project: { listRecent(): Promise; /** diff --git a/packages/desktop/src/shared/ipc-channels.ts b/packages/desktop/src/shared/ipc-channels.ts index fb5878a46..27b09de06 100644 --- a/packages/desktop/src/shared/ipc-channels.ts +++ b/packages/desktop/src/shared/ipc-channels.ts @@ -30,7 +30,7 @@ * existing channels is preferred over net-new hand-rolled channels until * that migration lands. * - * Count is 82 (ratchet cap 82). The 74→75 bump reconciled a merge collision: + * Count is 83 (ratchet cap 83). The 74→75 bump reconciled a merge collision: * the worktree selector (`ok:worktree:dispatch`) and the terminal-controls PR * (`ok:terminal:cli-installed-map`) each landed in the base tree's single free * slot concurrently. The 75→76 bump then unioned in the desktop @@ -46,8 +46,10 @@ * one-channel-per-operation design rather than a dispatch fold. The 81→82 bump * added the terminal clickable-links out-of-project reveal * (`ok:shell:reveal-external`): a distinct trust boundary from `reveal-asset` - * (uncontained + dialog-gated), so it could not fold onto it. Full rationale in - * the ratchet test header. + * (uncontained + dialog-gated), so it could not fold onto it. The 82→83 bump + * added one discriminated `ok:remote:dispatch` channel for all saved-machine, + * remote-directory, connection-test, and remote-open operations. Full + * rationale lives in the ratchet test header. */ import type { @@ -57,7 +59,11 @@ import type { EditorId, LocalOpOkInitResponse, OkFolderState, + RemoteDirectoryListing, ShareTargetStatusResponse, + SshConnectionTestResult, + SshMachine, + SshMachineDraft, TerminalCli, WorktreeCreateRequest, WorktreeCreateResult, @@ -118,6 +124,23 @@ export type OkSharingSetModeResult = * recover the per-operation typing despite the consolidated wire channel. */ export type OkSharingResult = OkSharingStatusResult | OkSharingSetModeResult; +/** One discriminated IPC channel backs the complete remote-project surface. */ +export type OkRemoteDispatchRequest = + | { kind: 'list-machines' } + | { kind: 'save-machine'; machine: SshMachineDraft } + | { kind: 'remove-machine'; machineId: string } + | { kind: 'test-machine'; machineId: string } + | { kind: 'list-directories'; machineId: string; path: string } + | { kind: 'open-project'; machineId: string; path: string }; + +export type OkRemoteDispatchResult = + | SshMachine[] + | SshMachine + | SshConnectionTestResult + | RemoteDirectoryListing + | boolean + | undefined; + /** Recent-project row as surfaced to the Navigator. */ export interface RecentProject { path: string; @@ -133,6 +156,12 @@ export interface RecentProject { * receive decision tree. */ gitRemoteUrl?: string; + /** SSH location metadata. `path` is the opaque machine-scoped project key. */ + remote?: { + machineId: string; + machineName: string; + path: string; + }; /** * Git-worktree relationship, computed at list-time (not persisted) so the * project switcher can nest linked worktrees under their main project. @@ -796,6 +825,15 @@ export interface RequestChannels { args: [request: { kind: 'status' } | { kind: 'set-mode'; mode: 'shared' | 'local-only' }]; result: OkSharingResult; }; + /** + * Saved SSH machines, folder browsing, connection testing, and remote + * project opening. A single discriminated channel avoids six new hand-rolled + * channels while main still validates every request arm independently. + */ + 'ok:remote:dispatch': { + args: [request: OkRemoteDispatchRequest]; + result: OkRemoteDispatchResult; + }; /** Read the LRU-capped recent-projects list from app state. */ 'ok:project:list-recent': { args: []; result: RecentProject[] }; /** Remove one project from the persisted recent-projects list. Does not delete files. */ diff --git a/packages/desktop/src/utility/pty-host.ts b/packages/desktop/src/utility/pty-host.ts index 914e90e38..19076241a 100644 --- a/packages/desktop/src/utility/pty-host.ts +++ b/packages/desktop/src/utility/pty-host.ts @@ -35,6 +35,10 @@ export interface PtyCreateMessage { * exits. Omitted for a plain terminal tab (spawned as the bare `$SHELL -l -i`). */ launchCommand?: string; + /** Main-owned executable override (for example `/usr/bin/ssh`). */ + executable?: string; + /** Exact argv paired with `executable`; never interpreted by a shell. */ + args?: string[]; } interface PtyInputMessage { type: 'input'; @@ -153,7 +157,12 @@ function asIncomingMessage(raw: unknown): PtyHostIncomingMessage | null { return typeof m.cwd === 'string' && typeof m.cols === 'number' && typeof m.rows === 'number' && - (m.launchCommand === undefined || typeof m.launchCommand === 'string') + (m.launchCommand === undefined || typeof m.launchCommand === 'string') && + ((m.executable === undefined && m.args === undefined) || + (typeof m.executable === 'string' && + m.executable.length > 0 && + Array.isArray(m.args) && + m.args.every((arg) => typeof arg === 'string'))) ? (raw as PtyHostIncomingMessage) : null; case 'input': @@ -288,9 +297,13 @@ export function setupPtyHost(deps: SetupPtyHostDeps): PtyHostHandle { } const shell = resolveShell(env, message.shell); const shellEnv = buildShellEnv(env); + const executable = message.executable ?? shell; + const args = message.executable + ? (message.args ?? []) + : buildShellArgs(shell, message.launchCommand); let pty: PtyProcessLike; try { - pty = deps.spawn(shell, buildShellArgs(shell, message.launchCommand), { + pty = deps.spawn(executable, args, { name: 'xterm-256color', cols: message.cols, rows: message.rows, diff --git a/packages/desktop/tests/integration/ipc-channel-count-ratchet.test.ts b/packages/desktop/tests/integration/ipc-channel-count-ratchet.test.ts index f3f44c605..f3c7590b9 100644 --- a/packages/desktop/tests/integration/ipc-channel-count-ratchet.test.ts +++ b/packages/desktop/tests/integration/ipc-channel-count-ratchet.test.ts @@ -321,7 +321,17 @@ const CHANNELS_SRC = readFileSync(SRC_PATH, 'utf-8'); * opposite security contracts. Single member; the typed-ipc migration remains the * committed end state, with the `ipc-channels.ts` header updated in lock-step. */ -const REQUEST_CHANNEL_CAP = 82; +/** + * Bumped from 82 to 83 for SSH-backed projects: + * + * - `ok:remote:dispatch` — a single discriminated channel folds saved-machine + * CRUD, fixed SSH connection tests, remote directory reads, and remote + * project opens. These operations cannot use the local `ok:project:open` + * path because SSH setup must remain main-only and must never expose raw + * SSH flags/commands to the renderer. Keeping every operation on one + * request union makes the feature +1 rather than six separate channels. + */ +const REQUEST_CHANNEL_CAP = 83; /** * Extract the body of an interface block by name. Returns the substring diff --git a/packages/desktop/tests/main/menu.test.ts b/packages/desktop/tests/main/menu.test.ts index 7e0e7b5d4..96a88d890 100644 --- a/packages/desktop/tests/main/menu.test.ts +++ b/packages/desktop/tests/main/menu.test.ts @@ -16,7 +16,11 @@ import { describe, expect, mock, test } from 'bun:test'; import type { MenuItemConstructorOptions } from 'electron'; import { buildMenuTemplate, type MenuDeps } from '../../src/main/menu.ts'; -type RecentRow = { path: string; name: string }; +type RecentRow = { + path: string; + name: string; + remote?: { machineName: string; path: string }; +}; function makeDeps(overrides: Partial = {}): MenuDeps { return { @@ -79,6 +83,22 @@ describe('buildMenuTemplate', () => { expect(sub?.[3]?.label).toBe('Clear menu'); }); + test('remote recents show their SSH machine and path instead of the opaque state key', () => { + const recents: RecentRow[] = [ + { + path: 'ssh:machine-1:%2Fsrv%2Fwiki', + name: 'wiki', + remote: { machineName: 'Build box', path: '/srv/wiki' }, + }, + ]; + const template = buildMenuTemplate(makeDeps({ getRecentProjects: () => recents })); + const openRecent = findByLabel(template, 'Recent project'); + const sub = openRecent?.submenu as MenuItemConstructorOptions[] | undefined; + + expect(sub?.[0]?.sublabel).toBe('Build box • /srv/wiki'); + expect(sub?.[0]?.sublabel).not.toContain('ssh:'); + }); + test('clamps at 10 entries even when more are present', () => { const recents: RecentRow[] = Array.from({ length: 15 }, (_, i) => ({ path: `/tmp/p${i}`, diff --git a/packages/desktop/tests/main/navigator-trust-boundary.test.ts b/packages/desktop/tests/main/navigator-trust-boundary.test.ts new file mode 100644 index 000000000..703e84ebf --- /dev/null +++ b/packages/desktop/tests/main/navigator-trust-boundary.test.ts @@ -0,0 +1,280 @@ +import { describe, expect, mock, test } from 'bun:test'; +import { resolve } from 'node:path'; +import { pathToFileURL } from 'node:url'; +import { + attachNavigatorTrustBoundary, + isAuthorizedProjectProxyIpcSender, + isTrustedNavigatorIpcSender, + isTrustedNavigatorSenderFrame, + isTrustedNavigatorUrl, + type NavigatorRendererTarget, +} from '../../src/main/navigator-trust-boundary.ts'; + +const PACKAGED_ENTRY = resolve( + '/Applications/Open Knowledge.app/Contents/Resources/app/index.html', +); +const PACKAGED_TARGET: NavigatorRendererTarget = { + rendererEntryPath: PACKAGED_ENTRY, + rendererDevUrl: null, +}; +const DEV_TARGET: NavigatorRendererTarget = { + rendererEntryPath: '/unused/index.html', + rendererDevUrl: 'http://localhost:5173/', +}; + +describe('isTrustedNavigatorUrl', () => { + test('packaged mode trusts only the exact loadFile entry (with optional query/hash)', () => { + const entryUrl = pathToFileURL(PACKAGED_ENTRY).href; + expect(isTrustedNavigatorUrl(entryUrl, PACKAGED_TARGET)).toBe(true); + expect(isTrustedNavigatorUrl(`${entryUrl}?v=1#/open`, PACKAGED_TARGET)).toBe(true); + + expect( + isTrustedNavigatorUrl( + pathToFileURL(resolve('/Applications/Open Knowledge.app/Contents/Resources/app/other.html')) + .href, + PACKAGED_TARGET, + ), + ).toBe(false); + expect(isTrustedNavigatorUrl(`${entryUrl}.attacker`, PACKAGED_TARGET)).toBe(false); + expect(isTrustedNavigatorUrl('https://example.com/index.html', PACKAGED_TARGET)).toBe(false); + expect(isTrustedNavigatorUrl('about:blank', PACKAGED_TARGET)).toBe(false); + }); + + test('dev mode trusts the configured HTTP origin so Vite reload/module paths keep working', () => { + expect(isTrustedNavigatorUrl('http://localhost:5173/', DEV_TARGET)).toBe(true); + expect(isTrustedNavigatorUrl('http://localhost:5173/@vite/client?t=1', DEV_TARGET)).toBe(true); + expect(isTrustedNavigatorUrl('http://localhost:5173/#/navigator', DEV_TARGET)).toBe(true); + + expect(isTrustedNavigatorUrl('https://localhost:5173/', DEV_TARGET)).toBe(false); + expect(isTrustedNavigatorUrl('http://localhost:5174/', DEV_TARGET)).toBe(false); + expect(isTrustedNavigatorUrl('http://localhost.attacker:5173/', DEV_TARGET)).toBe(false); + expect(isTrustedNavigatorUrl('blob:http://localhost:5173/0123', DEV_TARGET)).toBe(false); + }); + + test('fails closed for invalid URLs and invalid dev-target configuration', () => { + expect(isTrustedNavigatorUrl(undefined, DEV_TARGET)).toBe(false); + expect(isTrustedNavigatorUrl('not a URL', DEV_TARGET)).toBe(false); + expect( + isTrustedNavigatorUrl('file:///tmp/index.html', { + rendererEntryPath: '/tmp/index.html', + rendererDevUrl: 'file:///tmp/index.html', + }), + ).toBe(false); + }); +}); + +describe('isTrustedNavigatorSenderFrame', () => { + function mainFrame(url: string): { url: string; parent: null } { + return { url, parent: null }; + } + + test('accepts only a trusted top-level frame', () => { + const trustedMain = mainFrame('http://localhost:5173/#/navigator'); + expect(isTrustedNavigatorSenderFrame(trustedMain, DEV_TARGET)).toBe(true); + + const top = mainFrame('http://localhost:5173/'); + const trustedIframe = { url: 'http://localhost:5173/frame.html', parent: top }; + expect(isTrustedNavigatorSenderFrame(trustedIframe, DEV_TARGET)).toBe(false); + + expect(isTrustedNavigatorSenderFrame(mainFrame('https://attacker.example/'), DEV_TARGET)).toBe( + false, + ); + expect(isTrustedNavigatorSenderFrame(null, DEV_TARGET)).toBe(false); + expect(isTrustedNavigatorSenderFrame({ url: 'http://localhost:5173/' }, DEV_TARGET)).toBe( + false, + ); + }); + + test('fails closed when a disposed frame accessor throws', () => { + const frame = Object.defineProperties( + {}, + { + parent: { get: () => null }, + url: { + get: () => { + throw new Error('Render frame was disposed'); + }, + }, + }, + ); + expect(isTrustedNavigatorSenderFrame(frame, DEV_TARGET)).toBe(false); + }); +}); + +describe('isTrustedNavigatorIpcSender', () => { + function mainFrame(url: string): { url: string; parent: null } { + return { url, parent: null }; + } + + test('requires Navigator window identity plus a trusted top-level frame', () => { + const navigator = {}; + const trustedFrame = mainFrame('http://localhost:5173/#/navigator'); + expect(isTrustedNavigatorIpcSender(navigator, navigator, trustedFrame, DEV_TARGET)).toBe(true); + expect(isTrustedNavigatorIpcSender({}, navigator, trustedFrame, DEV_TARGET)).toBe(false); + expect(isTrustedNavigatorIpcSender(null, navigator, trustedFrame, DEV_TARGET)).toBe(false); + + const childFrame = { url: 'http://localhost:5173/frame.html', parent: trustedFrame }; + expect(isTrustedNavigatorIpcSender(navigator, navigator, childFrame, DEV_TARGET)).toBe(false); + expect( + isTrustedNavigatorIpcSender( + navigator, + navigator, + mainFrame('https://attacker.example/'), + DEV_TARGET, + ), + ).toBe(false); + }); +}); + +describe('isAuthorizedProjectProxyIpcSender', () => { + const mainFrame = (url: string): { url: string; parent: null } => ({ url, parent: null }); + + test('lets the trusted Navigator proxy a selected project', () => { + const navigator = {}; + expect( + isAuthorizedProjectProxyIpcSender({ + callerWindow: navigator, + navigatorWindow: navigator, + frame: mainFrame('http://localhost:5173/#/navigator'), + target: DEV_TARGET, + callerProjectPath: undefined, + requestedProjectPath: 'ssh:machine:%2Fsrv%2Fdocs', + }), + ).toBe(true); + }); + + test('restricts an editor to its sender-owned project', () => { + const navigator = {}; + const editor = {}; + const base = { + callerWindow: editor, + navigatorWindow: navigator, + frame: mainFrame('http://localhost:5173/'), + target: DEV_TARGET, + callerProjectPath: 'ssh:machine:%2Fsrv%2Fowned', + } as const; + + expect( + isAuthorizedProjectProxyIpcSender({ + ...base, + requestedProjectPath: 'ssh:machine:%2Fsrv%2Fowned', + }), + ).toBe(true); + expect( + isAuthorizedProjectProxyIpcSender({ + ...base, + requestedProjectPath: 'ssh:machine:%2Fsrv%2Fother', + }), + ).toBe(false); + }); + + test('rejects child, navigated, project-less, and malformed senders', () => { + const navigator = {}; + const editor = {}; + const trustedTop = mainFrame('http://localhost:5173/'); + const common = { + callerWindow: editor, + navigatorWindow: navigator, + target: DEV_TARGET, + callerProjectPath: undefined, + requestedProjectPath: '/projects/target', + } as const; + + expect( + isAuthorizedProjectProxyIpcSender({ + ...common, + frame: { url: 'http://localhost:5173/frame.html', parent: trustedTop }, + }), + ).toBe(false); + expect( + isAuthorizedProjectProxyIpcSender({ + ...common, + callerWindow: navigator, + frame: mainFrame('https://attacker.example/'), + }), + ).toBe(false); + expect(isAuthorizedProjectProxyIpcSender({ ...common, frame: trustedTop })).toBe(false); + expect( + isAuthorizedProjectProxyIpcSender({ + ...common, + callerWindow: navigator, + frame: trustedTop, + requestedProjectPath: '', + }), + ).toBe(false); + }); +}); + +describe('attachNavigatorTrustBoundary', () => { + function makeHarness() { + let openHandler: ((details: { url: string }) => { action: 'deny' }) | undefined; + let navigateHandler: ((event: { preventDefault(): void }, url: string) => void) | undefined; + const openExternal = mock(async (_url: string) => {}); + const log = mock(() => {}); + const webContents = { + setWindowOpenHandler: mock((handler: (details: { url: string }) => { action: 'deny' }) => { + openHandler = handler; + }), + on: mock( + ( + _event: 'will-navigate', + handler: (event: { preventDefault(): void }, url: string) => void, + ) => { + navigateHandler = handler; + }, + ), + }; + attachNavigatorTrustBoundary(webContents, DEV_TARGET, { openExternal, log }); + return { + open: (url: string) => openHandler?.({ url }), + navigate: (url: string) => { + const preventDefault = mock(() => {}); + navigateHandler?.({ preventDefault }, url); + return preventDefault; + }, + openExternal, + log, + webContents, + }; + } + + test('denies every child window and delegates only allowlisted external URLs', () => { + const harness = makeHarness(); + expect(harness.open('http://localhost:5173/#/navigator')).toEqual({ action: 'deny' }); + expect(harness.openExternal).not.toHaveBeenCalled(); + + expect(harness.open('https://docs.openknowledge.dev/')).toEqual({ action: 'deny' }); + expect(harness.openExternal).toHaveBeenCalledTimes(1); + expect(harness.openExternal).toHaveBeenLastCalledWith('https://docs.openknowledge.dev/'); + + expect(harness.open('javascript:alert(document.domain)')).toEqual({ action: 'deny' }); + expect(harness.openExternal).toHaveBeenCalledTimes(1); + expect(harness.log).toHaveBeenCalledWith( + expect.objectContaining({ + message: 'blocked outbound URL', + data: expect.objectContaining({ source: 'new-window' }), + }), + ); + }); + + test('allows trusted dev navigation but prevents and delegates cross-origin navigation', () => { + const harness = makeHarness(); + const trustedPrevent = harness.navigate('http://localhost:5173/@vite/client?t=2'); + expect(trustedPrevent).not.toHaveBeenCalled(); + expect(harness.openExternal).not.toHaveBeenCalled(); + + const externalPrevent = harness.navigate('https://example.com/guide'); + expect(externalPrevent).toHaveBeenCalledTimes(1); + expect(harness.openExternal).toHaveBeenCalledWith('https://example.com/guide'); + + const blockedPrevent = harness.navigate('file:///etc/passwd'); + expect(blockedPrevent).toHaveBeenCalledTimes(1); + expect(harness.openExternal).toHaveBeenCalledTimes(1); + }); + + test('attaches both defenses synchronously', () => { + const harness = makeHarness(); + expect(harness.webContents.setWindowOpenHandler).toHaveBeenCalledTimes(1); + expect(harness.webContents.on).toHaveBeenCalledWith('will-navigate', expect.any(Function)); + }); +}); diff --git a/packages/desktop/tests/main/navigator-window.test.ts b/packages/desktop/tests/main/navigator-window.test.ts index 7c74bc20d..24aeb69c0 100644 --- a/packages/desktop/tests/main/navigator-window.test.ts +++ b/packages/desktop/tests/main/navigator-window.test.ts @@ -149,8 +149,12 @@ describe('createNavigatorWindow — pendingPayload dom-ready gate (US-004)', () } }), executeJavaScript: mock(() => Promise.resolve()), - setWindowOpenHandler: mock(() => {}), - on: mock(() => {}), + setWindowOpenHandler: mock(() => { + loadCallOrder.push('set-window-open-handler'); + }), + on: mock(() => { + loadCallOrder.push('on-will-navigate'); + }), }, loadFile: mock(() => { loadCallOrder.push('loadFile'); @@ -194,6 +198,28 @@ describe('createNavigatorWindow — pendingPayload dom-ready gate (US-004)', () }; } + test('installs the navigation boundary before packaged or dev renderer loading begins', () => { + for (const rendererDevUrl of [null, 'http://localhost:5173/'] as const) { + const win = makeNavWindow(); + createNavigatorWindow({ + createWindow: () => win, + rendererEntryPath: '/fake/index.html', + rendererDevUrl, + openExternal: async () => {}, + appVersion: '9.9.9-test', + showGate: makeShowGate(), + }); + + const loadEvent = rendererDevUrl ? 'loadURL' : 'loadFile'; + expect(win.loadCallOrder.indexOf('set-window-open-handler')).toBeLessThan( + win.loadCallOrder.indexOf(loadEvent), + ); + expect(win.loadCallOrder.indexOf('on-will-navigate')).toBeLessThan( + win.loadCallOrder.indexOf(loadEvent), + ); + } + }); + test("cold path: pendingPayload registers webContents.once('dom-ready') BEFORE loadFile", () => { // Mirrors window-manager's pendingDeepLinkDoc regression: registering // the listener after loadFile silently misses dom-ready on a fast load. @@ -201,6 +227,7 @@ describe('createNavigatorWindow — pendingPayload dom-ready gate (US-004)', () createNavigatorWindow({ createWindow: () => win, rendererEntryPath: '/fake/index.html', + openExternal: async () => {}, appVersion: '9.9.9-test', showGate: makeShowGate(), pendingPayload: makePayload(), @@ -243,6 +270,7 @@ describe('createNavigatorWindow — pendingPayload dom-ready gate (US-004)', () createNavigatorWindow({ createWindow: () => win, rendererEntryPath: '/fake/index.html', + openExternal: async () => {}, appVersion: '9.9.9-test', showGate: makeShowGate(), }); @@ -268,6 +296,7 @@ describe('createNavigatorWindow — pendingPayload dom-ready gate (US-004)', () createNavigatorWindow({ createWindow: () => win, rendererEntryPath: '/fake/index.html', + openExternal: async () => {}, appVersion: '9.9.9-test', showGate: makeShowGate(), pendingPayload: payload, diff --git a/packages/desktop/tests/main/remote-editor-trust-boundary.test.ts b/packages/desktop/tests/main/remote-editor-trust-boundary.test.ts new file mode 100644 index 000000000..2e973c3f4 --- /dev/null +++ b/packages/desktop/tests/main/remote-editor-trust-boundary.test.ts @@ -0,0 +1,216 @@ +import { describe, expect, mock, test } from 'bun:test'; +import { resolve } from 'node:path'; +import { pathToFileURL } from 'node:url'; +import { + attachRemoteEditorTrustBoundary, + isRemoteProjectApiUrl, + isSafeRemoteExternalUrl, + isTrustedRemoteEditorUrl, + type RemoteEditorRendererTarget, +} from '../../src/main/remote-editor-trust-boundary.ts'; + +const PACKAGED_ENTRY = resolve( + '/Applications/Open Knowledge.app/Contents/Resources/app/index.html', +); +const PACKAGED_TARGET: RemoteEditorRendererTarget = { + rendererEntryPath: PACKAGED_ENTRY, + rendererDevUrl: null, +}; +const DEV_TARGET: RemoteEditorRendererTarget = { + rendererEntryPath: '/unused/index.html', + rendererDevUrl: 'http://localhost:5173/', +}; +const API_ORIGIN = 'http://127.0.0.1:45123'; + +describe('isTrustedRemoteEditorUrl', () => { + test('packaged mode trusts only the exact renderer file with query/hash state', () => { + const entryUrl = pathToFileURL(PACKAGED_ENTRY).href; + expect(isTrustedRemoteEditorUrl(entryUrl, PACKAGED_TARGET)).toBe(true); + expect(isTrustedRemoteEditorUrl(`${entryUrl}?v=1#/doc`, PACKAGED_TARGET)).toBe(true); + + expect( + isTrustedRemoteEditorUrl( + pathToFileURL( + resolve('/Applications/Open Knowledge.app/Contents/Resources/app/remote.html'), + ).href, + PACKAGED_TARGET, + ), + ).toBe(false); + expect(isTrustedRemoteEditorUrl('data:text/html,', PACKAGED_TARGET)).toBe( + false, + ); + expect(isTrustedRemoteEditorUrl('blob:null/abc', PACKAGED_TARGET)).toBe(false); + expect(isTrustedRemoteEditorUrl('about:blank', PACKAGED_TARGET)).toBe(false); + }); + + test('dev mode trusts only the configured Vite scheme and origin', () => { + expect(isTrustedRemoteEditorUrl('http://localhost:5173/', DEV_TARGET)).toBe(true); + expect(isTrustedRemoteEditorUrl('http://localhost:5173/@vite/client?t=1', DEV_TARGET)).toBe( + true, + ); + expect(isTrustedRemoteEditorUrl('http://localhost:5173/#/doc', DEV_TARGET)).toBe(true); + + expect(isTrustedRemoteEditorUrl('https://localhost:5173/', DEV_TARGET)).toBe(false); + expect(isTrustedRemoteEditorUrl('http://localhost:5174/', DEV_TARGET)).toBe(false); + expect(isTrustedRemoteEditorUrl('blob:http://localhost:5173/abc', DEV_TARGET)).toBe(false); + expect(isTrustedRemoteEditorUrl('not a URL', DEV_TARGET)).toBe(false); + }); +}); + +describe('remote destination classification', () => { + test('recognizes the tunnel origin and equivalent loopback aliases on its port', () => { + expect(isRemoteProjectApiUrl(`${API_ORIGIN}/notes/attack.html`, API_ORIGIN)).toBe(true); + expect(isRemoteProjectApiUrl('http://localhost:45123/notes/attack.html', API_ORIGIN)).toBe( + true, + ); + expect(isRemoteProjectApiUrl('http://127.0.0.2:45123/raw.htm', API_ORIGIN)).toBe(true); + expect(isRemoteProjectApiUrl('http://localhost.:45123/raw.htm', API_ORIGIN)).toBe(true); + expect(isRemoteProjectApiUrl('http://wiki.localhost.:45123/raw.htm', API_ORIGIN)).toBe(true); + expect(isRemoteProjectApiUrl('http://[::ffff:127.42.0.1]:45123/raw.htm', API_ORIGIN)).toBe( + true, + ); + expect(isRemoteProjectApiUrl('http://[::ffff:0.0.0.0]:45123/raw.htm', API_ORIGIN)).toBe(true); + + expect(isRemoteProjectApiUrl('http://localhost:45124/notes/attack.html', API_ORIGIN)).toBe( + false, + ); + expect(isRemoteProjectApiUrl('http://[::ffff:192.0.2.1]:45123/raw.htm', API_ORIGIN)).toBe( + false, + ); + expect(isRemoteProjectApiUrl('http://localhost.attacker.example:45123/', API_ORIGIN)).toBe( + false, + ); + }); + + test('allows only ordinary non-loopback web/mail destinations', () => { + expect(isSafeRemoteExternalUrl('https://docs.openknowledge.dev/guide')).toBe(true); + expect(isSafeRemoteExternalUrl('http://example.com/guide')).toBe(true); + expect(isSafeRemoteExternalUrl('mailto:hello@example.com')).toBe(true); + expect(isSafeRemoteExternalUrl('http://[::ffff:192.0.2.1]/guide')).toBe(true); + expect(isSafeRemoteExternalUrl('http://localhost.attacker.example/guide')).toBe(true); + + expect(isSafeRemoteExternalUrl('http://localhost:45123/attack.html')).toBe(false); + expect(isSafeRemoteExternalUrl('http://localhost.:45123/attack.html')).toBe(false); + expect(isSafeRemoteExternalUrl('http://wiki.localhost.:45123/attack.html')).toBe(false); + expect(isSafeRemoteExternalUrl('http://[::ffff:127.42.0.1]:45123/attack.html')).toBe(false); + expect(isSafeRemoteExternalUrl('http://[::ffff:0.0.0.0]:45123/attack.html')).toBe(false); + expect(isSafeRemoteExternalUrl('http://127.0.0.1:9000/admin')).toBe(false); + expect(isSafeRemoteExternalUrl('file:///etc/passwd')).toBe(false); + expect(isSafeRemoteExternalUrl('data:text/html,attack')).toBe(false); + expect(isSafeRemoteExternalUrl('blob:null/attack')).toBe(false); + expect(isSafeRemoteExternalUrl('javascript:alert(1)')).toBe(false); + expect(isSafeRemoteExternalUrl('openknowledge://open?project=/tmp/private')).toBe(false); + expect(isSafeRemoteExternalUrl('codex://new?path=/tmp/private')).toBe(false); + }); +}); + +describe('attachRemoteEditorTrustBoundary', () => { + function makeHarness(target: RemoteEditorRendererTarget = DEV_TARGET) { + let openHandler: ((details: { url: string }) => { action: 'deny' }) | undefined; + let navigateHandler: ((event: { preventDefault(): void }, url: string) => void) | undefined; + const openExternal = mock(async (_url: string) => {}); + const executeJavaScript = mock(async (_code: string) => {}); + const log = mock(() => {}); + const webContents = { + setWindowOpenHandler: mock((handler: (details: { url: string }) => { action: 'deny' }) => { + openHandler = handler; + }), + on: mock( + ( + _event: 'will-navigate', + handler: (event: { preventDefault(): void }, url: string) => void, + ) => { + navigateHandler = handler; + }, + ), + executeJavaScript, + }; + attachRemoteEditorTrustBoundary(webContents, target, { + apiOrigin: API_ORIGIN, + openExternal, + log, + }); + return { + open: (url: string) => openHandler?.({ url }), + navigate: (url: string) => { + const preventDefault = mock(() => {}); + navigateHandler?.({ preventDefault }, url); + return preventDefault; + }, + executeJavaScript, + openExternal, + log, + webContents, + }; + } + + test('denies every child window while preserving trusted in-app hash navigation', () => { + const harness = makeHarness(); + expect(harness.open('http://localhost:5173/#/doc')).toEqual({ action: 'deny' }); + expect(harness.executeJavaScript).toHaveBeenCalledWith('window.location.hash = "#/doc";'); + + expect(harness.open(`${API_ORIGIN}/notes/attack.html`)).toEqual({ action: 'deny' }); + expect(harness.open('http://localhost:45123/raw.htm')).toEqual({ action: 'deny' }); + expect(harness.open('http://localhost.:45123/raw.htm')).toEqual({ action: 'deny' }); + expect(harness.open('http://[::ffff:127.42.0.1]:45123/raw.htm')).toEqual({ action: 'deny' }); + expect(harness.openExternal).not.toHaveBeenCalled(); + + expect(harness.open('https://example.com/guide')).toEqual({ action: 'deny' }); + expect(harness.openExternal).toHaveBeenCalledTimes(1); + expect(harness.openExternal).toHaveBeenCalledWith('https://example.com/guide'); + + expect(harness.open('file:///tmp/attack.html')).toEqual({ action: 'deny' }); + expect(harness.open('data:text/html,attack')).toEqual({ action: 'deny' }); + expect(harness.open('blob:null/attack')).toEqual({ action: 'deny' }); + expect(harness.open('openknowledge://open?project=/tmp/private')).toEqual({ action: 'deny' }); + expect(harness.openExternal).toHaveBeenCalledTimes(1); + }); + + test('allows only renderer top-level navigation and prevents remote or opaque documents', () => { + const harness = makeHarness(); + const rendererPrevent = harness.navigate('http://localhost:5173/@vite/client?t=2'); + expect(rendererPrevent).not.toHaveBeenCalled(); + + for (const url of [ + `${API_ORIGIN}/notes/attack.html`, + 'http://localhost:45123/notes/attack.html', + 'http://localhost.:45123/notes/attack.html', + 'http://[::ffff:127.42.0.1]:45123/notes/attack.html', + 'file:///tmp/attack.html', + 'data:text/html,attack', + 'blob:null/attack', + 'javascript:alert(1)', + 'not a URL', + ]) { + expect(harness.navigate(url)).toHaveBeenCalledTimes(1); + } + expect(harness.openExternal).not.toHaveBeenCalled(); + + const externalPrevent = harness.navigate('https://example.com/guide'); + expect(externalPrevent).toHaveBeenCalledTimes(1); + expect(harness.openExternal).toHaveBeenCalledTimes(1); + expect(harness.openExternal).toHaveBeenCalledWith('https://example.com/guide'); + }); + + test('pins packaged navigation to the exact renderer entry, not null-origin peers', () => { + const harness = makeHarness(PACKAGED_TARGET); + const entry = pathToFileURL(PACKAGED_ENTRY).href; + expect(harness.navigate(`${entry}#/doc`)).not.toHaveBeenCalled(); + expect(harness.navigate('file:///tmp/remote.html')).toHaveBeenCalledTimes(1); + expect(harness.navigate('data:text/html,attack')).toHaveBeenCalledTimes(1); + expect(harness.navigate('blob:null/attack')).toHaveBeenCalledTimes(1); + }); + + test('attaches both defenses synchronously and rejects an invalid API origin', () => { + const harness = makeHarness(); + expect(harness.webContents.setWindowOpenHandler).toHaveBeenCalledTimes(1); + expect(harness.webContents.on).toHaveBeenCalledWith('will-navigate', expect.any(Function)); + + expect(() => + attachRemoteEditorTrustBoundary(harness.webContents, DEV_TARGET, { + apiOrigin: 'not a URL', + openExternal: harness.openExternal, + }), + ).toThrow('invalid API origin'); + }); +}); diff --git a/packages/desktop/tests/main/remote-project-service.test.ts b/packages/desktop/tests/main/remote-project-service.test.ts new file mode 100644 index 000000000..1e4d937f4 --- /dev/null +++ b/packages/desktop/tests/main/remote-project-service.test.ts @@ -0,0 +1,1323 @@ +import { describe, expect, mock, test } from 'bun:test'; +import { spawnSync } from 'node:child_process'; +import { createHash } from 'node:crypto'; +import { EventEmitter } from 'node:events'; +import { + chmodSync, + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + statSync, + symlinkSync, + writeFileSync, +} from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { PassThrough } from 'node:stream'; + +import { PROTOCOL_VERSION, type SshMachine } from '@inkeep/open-knowledge-core'; +import { RUNTIME_VERSION } from '@inkeep/open-knowledge-server'; +import { + assertSafeTunnelSshConfig, + buildIsCommandAvailableCommand, + buildListDirectoriesCommand, + buildPosixLoginShellCommand, + buildRemoteCompanionInstallCommand, + buildRemoteCompanionProbeCommand, + buildRemoteInspectCommand, + buildRemoteMachineTestCommand, + buildRemoteServeCommand, + buildRemoteTerminalConsentCommand, + buildRemoteTunnelSentinelCommand, + buildSshCommandArgs, + buildSshEffectiveConfigArgs, + buildSshTerminalArgs, + buildSshTunnelArgs, + encodeRemoteDirectoryRequest, + fingerprintTunnelSshConfig, + parseRemoteCompanionStatus, + parseRemoteErrorLine, + parseRemoteInspection, + parseRemoteMachineTest, + parseRemoteReadyLine, + parseRemoteTerminalConsent, + quotePosix, + REMOTE_COMMAND_MARKER, + REMOTE_COMPANION_INSTALL_NODE_SCRIPT, + REMOTE_COMPANION_MARKER, + REMOTE_COMPANION_PROBE_NODE_SCRIPT, + REMOTE_DIRECTORIES_MARKER, + REMOTE_ERROR_MARKER, + REMOTE_INSPECT_MARKER, + REMOTE_LIST_DIRECTORIES_NODE_SCRIPT, + REMOTE_READY_MARKER, + REMOTE_TERMINAL_CONSENT_MARKER, + REMOTE_TEST_MARKER, + REMOTE_TUNNEL_READY_MARKER, + type RemoteChildProcess, + RemoteProjectError, + RemoteProjectService, + type RemoteProjectServiceDeps, + type RemoteSpawn, + type RemoteSpawnOptions, + readBoundedResponseText, + validateRemoteExecutableName, + validateRemoteProjectPath, + validateSshMachine, +} from '../../src/main/remote-project-service.ts'; + +const TEST_COMPANION_DIGEST = 'a'.repeat(64); +const TEST_NONCE = 'A'.repeat(43); + +const MACHINE: SshMachine = { + id: 'build-box', + name: 'Build box', + host: 'developer@build-box', + port: 2222, +}; + +function readyMarker(overrides: Record = {}): string { + return `${REMOTE_READY_MARKER}${JSON.stringify({ + v: 1, + nonce: TEST_NONCE, + port: 43123, + projectPath: '/srv/wiki', + platform: 'linux', + pathSeparator: '/', + protocolVersion: PROTOCOL_VERSION, + runtimeVersion: RUNTIME_VERSION, + capabilities: ['http', 'ws'], + owned: true, + ...overrides, + })}`; +} + +function emitTunnelReady(child: FakeChild): void { + queueMicrotask(() => + child.writeStdout( + `${REMOTE_TUNNEL_READY_MARKER}${JSON.stringify({ v: 1, nonce: TEST_NONCE, ready: true })}\n`, + ), + ); +} + +function testMarker(status: string, nonce = TEST_NONCE): string { + return `${REMOTE_TEST_MARKER}${JSON.stringify({ v: 1, nonce, status })}`; +} + +function companionMarker(status: string, nonce = TEST_NONCE): string { + return `${REMOTE_COMPANION_MARKER}${JSON.stringify({ v: 1, nonce, status })}`; +} + +function commandMarker(available: boolean, nonce = TEST_NONCE): string { + return `${REMOTE_COMMAND_MARKER}${JSON.stringify({ v: 1, nonce, available })}`; +} + +function directoryMarker(payload: Record, nonce = TEST_NONCE): string { + return `${REMOTE_DIRECTORIES_MARKER}${JSON.stringify({ v: 1, nonce, ...payload })}`; +} + +class FakeChild extends EventEmitter { + readonly lifecycle: string[] = []; + readonly stdinPayloads: Uint8Array[] = []; + onStdinEnd: (() => void) | undefined; + readonly stdin = { + end: mock((data?: Uint8Array) => { + if (data !== undefined) this.stdinPayloads.push(data); + this.lifecycle.push('stdin:end'); + this.onStdinEnd?.(); + }), + }; + readonly stdout = new PassThrough(); + readonly stderr = new PassThrough(); + readonly kill = mock((_signal?: NodeJS.Signals) => { + this.lifecycle.push('kill'); + return true; + }); + + writeStdout(value: string | Uint8Array): void { + this.stdout.write(value); + } + + writeStderr(value: string | Uint8Array): void { + this.stderr.write(value); + } + + close(code: number | null): void { + this.stdout.end(); + this.stderr.end(); + this.emit('close', code, null); + } + + asRemoteChild(): RemoteChildProcess { + return this as unknown as RemoteChildProcess; + } +} + +interface SpawnCall { + readonly file: string; + readonly args: readonly string[]; + readonly options: RemoteSpawnOptions; + readonly child: FakeChild; +} + +function recordingSpawn(onSpawn: (call: SpawnCall, index: number) => void): { + readonly spawn: RemoteSpawn; + readonly calls: SpawnCall[]; +} { + const calls: SpawnCall[] = []; + const spawn: RemoteSpawn = (file, args, options) => { + const child = new FakeChild(); + const call = { file, args: [...args], options, child }; + calls.push(call); + onSpawn(call, calls.length - 1); + return child.asRemoteChild(); + }; + return { spawn, calls }; +} + +function recordingProjectSpawn(onSpawn: (call: SpawnCall, index: number) => void): { + readonly spawn: RemoteSpawn; + readonly calls: SpawnCall[]; +} { + const calls: SpawnCall[] = []; + const spawn: RemoteSpawn = (file, args, options) => { + const child = new FakeChild(); + const call = { file, args: [...args], options, child }; + if (args.at(-1)?.includes(REMOTE_TEST_MARKER)) { + queueMicrotask(() => { + child.writeStdout(`${testMarker('ok')}\n`); + child.close(0); + }); + } else { + calls.push(call); + onSpawn(call, calls.length - 1); + } + return child.asRemoteChild(); + }; + return { spawn, calls }; +} + +function serviceDeps(spawn: RemoteSpawn, overrides: RemoteProjectServiceDeps = {}) { + return { + spawn, + sshPath: '/test/system-ssh', + allocateLocalPort: async () => 45123, + fetch: async () => ({ + ok: true, + status: 200, + text: async () => JSON.stringify({ collabUrl: 'ws://localhost:43123/collab', port: 43123 }), + }), + sleep: async () => {}, + createNonce: () => TEST_NONCE, + inspectTunnelConfig: async () => 'test-connection-fingerprint', + ensureRemoteCompanion: async () => TEST_COMPANION_DIGEST, + ...overrides, + } satisfies RemoteProjectServiceDeps; +} + +describe('SSH machine and path validation', () => { + test('accepts and copies the non-secret machine allowlist', () => { + expect(validateSshMachine(MACHINE)).toEqual(MACHINE); + expect(validateSshMachine({ id: 'x', name: 'X', host: 'ssh-config-alias' })).toEqual({ + id: 'x', + name: 'X', + host: 'ssh-config-alias', + }); + }); + + test('rejects unsafe destinations, invalid ports, and secret-shaped extra fields', () => { + for (const host of [ + '', + '-proxy', + 'host name', + 'host\nname', + ' host', + // biome-ignore lint/suspicious/noTemplateCurlyInString: literal shell-injection fixture + 'x;touch${IFS}/tmp/pwn', + 'wild*card', + '[::1]', + ]) { + expect(() => validateSshMachine({ ...MACHINE, host })).toThrow(RemoteProjectError); + } + for (const port of [0, 65_536, 22.5, '22']) { + expect(() => validateSshMachine({ ...MACHINE, port })).toThrow(RemoteProjectError); + } + expect(() => validateSshMachine({ ...MACHINE, password: 'secret' })).toThrow( + 'unsupported fields', + ); + }); + + test('accepts only absolute and home-relative POSIX project paths', () => { + expect(validateRemoteProjectPath('/srv/a project')).toBe('/srv/a project'); + expect(validateRemoteProjectPath('~/a project')).toBe('~/a project'); + expect(validateRemoteProjectPath('~')).toBe('~'); + for (const path of ['', '.', 'relative/project', '../project', '/bad\npath']) { + expect(() => validateRemoteProjectPath(path)).toThrow(RemoteProjectError); + } + }); +}); + +describe('safe command construction', () => { + test('quotes POSIX shell data without exposing quote or semicolon injection', () => { + expect(quotePosix("a'b;c")).toBe(`'a'"'"'b;c'`); + const path = "/srv/a'; touch /tmp/pwn; #"; + const command = buildRemoteServeCommand(path, TEST_COMPANION_DIGEST, { + initialize: false, + nonce: TEST_NONCE, + }); + expect(command).toContain(`exec "\${SHELL:-/bin/sh}" -lic`); + expect(command).toContain('--nonce'); + expect(command).toBe( + buildPosixLoginShellCommand( + `cd ${quotePosix(path)} && OK_CONSOLE_LEVEL=silent exec node --no-warnings "$HOME/.ok/remote/servers/${TEST_COMPANION_DIGEST}/remote-companion.mjs" --nonce '${TEST_NONCE}' serve`, + ), + ); + expect(command).not.toContain('cd /srv/a'); + const expectedPath = Buffer.from(path).toString('base64url'); + expect( + buildRemoteServeCommand(path, TEST_COMPANION_DIGEST, { + initialize: true, + nonce: TEST_NONCE, + }), + ).toBe( + buildPosixLoginShellCommand( + `cd ${quotePosix(path)} && OK_CONSOLE_LEVEL=silent exec node --no-warnings "$HOME/.ok/remote/servers/${TEST_COMPANION_DIGEST}/remote-companion.mjs" --nonce '${TEST_NONCE}' serve --initialize --expected-path ${quotePosix(expectedPath)}`, + ), + ); + expect(() => + buildRemoteServeCommand('~/wiki', TEST_COMPANION_DIGEST, { + initialize: true, + nonce: TEST_NONCE, + }), + ).toThrow('canonical absolute folder path'); + const inspectCommand = buildRemoteInspectCommand(path, TEST_COMPANION_DIGEST, TEST_NONCE); + expect(inspectCommand).toContain('remote-companion.mjs'); + expect(inspectCommand).toContain('inspect'); + }); + + test('uses a constant Node script with a base64url JSON directory payload', () => { + const path = "/srv/O'Reilly docs"; + const payload = encodeRemoteDirectoryRequest(path, TEST_NONCE); + expect(JSON.parse(Buffer.from(payload, 'base64url').toString('utf8'))).toEqual({ + v: 1, + nonce: TEST_NONCE, + path, + }); + const command = buildListDirectoriesCommand(path, TEST_NONCE); + expect(command).toContain('node -e'); + expect(command).toContain(payload); + expect(command).not.toContain(path); + expect(REMOTE_LIST_DIRECTORIES_NODE_SCRIPT).toContain(REMOTE_DIRECTORIES_MARKER); + expect(REMOTE_LIST_DIRECTORIES_NODE_SCRIPT).toContain('fs.opendir'); + expect(REMOTE_LIST_DIRECTORIES_NODE_SCRIPT).toContain("error: 'too-many'"); + }); + + test('builds hardened command and loopback tunnel argv without weakening known hosts', () => { + const commandArgs = buildSshCommandArgs(MACHINE, 'fixed-command'); + expect(commandArgs).toContain('BatchMode=yes'); + expect(commandArgs).toContain('ClearAllForwardings=yes'); + expect(commandArgs).toContain('ServerAliveInterval=15'); + expect(commandArgs).toContain('RemoteCommand=none'); + expect(commandArgs).toContain('SessionType=default'); + expect(commandArgs).toContain('ForwardAgent=no'); + expect(commandArgs).toContain('ControlPath=none'); + expect(commandArgs).toContain('ForkAfterAuthentication=no'); + expect(commandArgs).toContain('developer@build-box'); + expect(commandArgs).toContain('fixed-command'); + expect(commandArgs.join(' ')).not.toContain('StrictHostKeyChecking'); + expect(commandArgs.join(' ')).not.toContain('UserKnownHostsFile'); + + const tunnelArgs = buildSshTunnelArgs(MACHINE, 45123, 43123, TEST_NONCE); + expect(tunnelArgs).toContain('ExitOnForwardFailure=yes'); + expect(tunnelArgs).not.toContain('-N'); + expect(tunnelArgs).toContain('127.0.0.1:45123:127.0.0.1:43123'); + expect(tunnelArgs.at(-1)).toBe(buildRemoteTunnelSentinelCommand(TEST_NONCE)); + // The tunnel must override an inherited `yes` so Desktop's explicit -L is + // retained. The effective-config probe uses this exact posture too. + expect(tunnelArgs).not.toContain('ClearAllForwardings=yes'); + expect(tunnelArgs).toContain('ClearAllForwardings=no'); + + const configArgs = buildSshEffectiveConfigArgs(MACHINE); + expect(configArgs).toContain('-G'); + expect(configArgs).toContain('ControlPath=none'); + expect(configArgs).toContain('ClearAllForwardings=no'); + expect(configArgs.at(-1)).toBe(MACHINE.host); + }); + + test('rejects effective Host configs with unrelated forwards and fingerprints routing identity', () => { + const safe = [ + 'host build-box', + 'hostname 10.0.0.12', + 'user developer', + 'port 2222', + 'proxyjump bastion', + 'canonicalizehostname false', + ].join('\n'); + expect(() => assertSafeTunnelSshConfig(safe)).not.toThrow(); + expect(fingerprintTunnelSshConfig(safe)).toHaveLength(64); + expect(fingerprintTunnelSshConfig(safe)).not.toBe( + fingerprintTunnelSshConfig(safe.replace('10.0.0.12', '10.0.0.13')), + ); + + for (const line of [ + 'localforward 127.0.0.1:8000 127.0.0.1:8000', + 'remoteforward 127.0.0.1:9000 127.0.0.1:9000', + 'dynamicforward 127.0.0.1:1080', + ]) { + expect(() => assertSafeTunnelSshConfig(`${safe}\n${line}`)).toThrow('Add a clean Host alias'); + } + }); + + test('remote terminal safely runs launchCommand, then returns to an interactive shell', () => { + const path = "/srv/project'; safe"; + const launchCommand = `claude --append-system-prompt 'team docs'`; + const args = buildSshTerminalArgs(MACHINE, path, launchCommand); + expect(args).toContain('-tt'); + expect(args).toContain('ClearAllForwardings=yes'); + expect(args.at(-1)).toBe( + buildPosixLoginShellCommand( + `cd ${quotePosix(path)} && ${launchCommand}; exec "\${SHELL:-/bin/sh}" -l -i`, + ), + ); + }); + + test('remote terminal with no launch command opens a login-interactive shell', () => { + const args = buildSshTerminalArgs(MACHINE, '/srv/project'); + expect(args.at(-1)).toBe( + buildPosixLoginShellCommand( + `cd ${quotePosix('/srv/project')} && exec "\${SHELL:-/bin/sh}" -l -i`, + ), + ); + }); + + test('machine and command probes are fixed, marker-based login-shell commands', () => { + const machineProbe = buildRemoteMachineTestCommand(TEST_NONCE); + expect(machineProbe).toContain('uname -s'); + expect(machineProbe).toContain('platform-unsupported'); + expect(machineProbe).toContain('node_major='); + expect(machineProbe).toContain('git --version'); + expect(machineProbe).not.toContain('command -v ok'); + expect(machineProbe).toContain('node-too-old'); + expect(machineProbe).toContain('git-too-old'); + + expect(buildIsCommandAvailableCommand('claude', TEST_NONCE)).toContain('command -v'); + expect(buildIsCommandAvailableCommand('claude', TEST_NONCE)).toContain(REMOTE_COMMAND_MARKER); + expect(validateRemoteExecutableName('open-code_2.0')).toBe('open-code_2.0'); + for (const unsafe of ['', '-claude', '../claude', 'claude; touch /tmp/pwn', 'a b']) { + expect(() => buildIsCommandAvailableCommand(unsafe, TEST_NONCE)).toThrow('name is invalid'); + } + }); + + test('builds the terminal-consent probe under the selected remote project', () => { + const path = "/srv/team's wiki"; + const command = buildRemoteTerminalConsentCommand(path, TEST_COMPANION_DIGEST, TEST_NONCE); + expect(command).toContain('terminal-consent'); + expect(command).toBe( + buildPosixLoginShellCommand( + `cd ${quotePosix(path)} && OK_CONSOLE_LEVEL=silent exec node --no-warnings "$HOME/.ok/remote/servers/${TEST_COMPANION_DIGEST}/remote-companion.mjs" --nonce '${TEST_NONCE}' terminal-consent`, + ), + ); + }); + + test('builds marker-framed companion probes and permission-safe atomic installs', () => { + const probe = buildRemoteCompanionProbeCommand(TEST_COMPANION_DIGEST, TEST_NONCE); + const install = buildRemoteCompanionInstallCommand(TEST_COMPANION_DIGEST, 42, TEST_NONCE); + expect(probe).toContain(REMOTE_COMPANION_MARKER); + expect(install).toContain('mkdtemp'); + expect(install).toContain('0o700'); + expect(install).toContain('0o600'); + expect(install).toContain('sha256'); + expect(install).toContain('rename'); + expect(parseRemoteCompanionStatus(`${companionMarker('ready')}\n`, TEST_NONCE)).toBe('ready'); + expect(parseRemoteCompanionStatus(`${companionMarker('installed')}\n`, TEST_NONCE)).toBe( + 'installed', + ); + expect(() => buildRemoteCompanionProbeCommand('../unsafe', TEST_NONCE)).toThrow( + 'identity is invalid', + ); + }); + + test('the remote installer writes a private verified file and the probe detects tampering', () => { + const home = mkdtempSync(join(tmpdir(), 'ok-remote-home-')); + const companion = Buffer.from('#!/usr/bin/env node\nconsole.log("remote");\n'); + const digest = createHash('sha256').update(companion).digest('hex'); + const file = join(home, '.ok', 'remote', 'servers', digest, 'remote-companion.mjs'); + try { + const install = spawnSync( + 'node', + [ + '-e', + REMOTE_COMPANION_INSTALL_NODE_SCRIPT, + digest, + String(companion.byteLength), + TEST_NONCE, + ], + { + input: companion, + encoding: 'utf8', + env: { ...process.env, HOME: home }, + }, + ); + expect(install.status).toBe(0); + expect(install.stdout).toBe(`${companionMarker('installed')}\n`); + expect(readFileSync(file)).toEqual(companion); + expect(statSync(file).mode & 0o777).toBe(0o600); + expect(statSync(join(home, '.ok', 'remote')).mode & 0o777).toBe(0o700); + expect(statSync(join(home, '.ok', 'remote', 'servers')).mode & 0o777).toBe(0o700); + + const probe = () => + spawnSync('node', ['-e', REMOTE_COMPANION_PROBE_NODE_SCRIPT, digest, TEST_NONCE], { + encoding: 'utf8', + env: { ...process.env, HOME: home }, + }); + expect(probe().stdout).toBe(`${companionMarker('ready')}\n`); + writeFileSync(file, 'tampered', { mode: 0o600 }); + expect(probe().stdout).toBe(`${companionMarker('missing')}\n`); + writeFileSync(file, companion, { mode: 0o600 }); + chmodSync(join(home, '.ok', 'remote'), 0o755); + expect(probe().status).toBe(1); + } finally { + rmSync(home, { recursive: true, force: true }); + } + }); + + test('the remote installer rejects symlinked or writable managed parents', () => { + const companion = Buffer.from('remote companion'); + const digest = createHash('sha256').update(companion).digest('hex'); + const runInstall = (home: string) => + spawnSync( + 'node', + [ + '-e', + REMOTE_COMPANION_INSTALL_NODE_SCRIPT, + digest, + String(companion.byteLength), + TEST_NONCE, + ], + { input: companion, encoding: 'utf8', env: { ...process.env, HOME: home } }, + ); + + const symlinkHome = mkdtempSync(join(tmpdir(), 'ok-remote-symlink-home-')); + const target = mkdtempSync(join(tmpdir(), 'ok-remote-symlink-target-')); + try { + symlinkSync(target, join(symlinkHome, '.ok')); + expect(runInstall(symlinkHome).status).toBe(1); + } finally { + rmSync(symlinkHome, { recursive: true, force: true }); + rmSync(target, { recursive: true, force: true }); + } + + const writableHome = mkdtempSync(join(tmpdir(), 'ok-remote-writable-home-')); + try { + mkdirSync(join(writableHome, '.ok'), { mode: 0o770 }); + chmodSync(join(writableHome, '.ok'), 0o770); + expect(runInstall(writableHome).status).toBe(1); + } finally { + rmSync(writableHome, { recursive: true, force: true }); + } + }); +}); + +describe('remote readiness protocol', () => { + test('parses v1 readiness for a supported POSIX platform', () => { + expect( + parseRemoteReadyLine( + readyMarker({ platform: 'darwin', projectPath: '/srv/ wiki ' }), + TEST_NONCE, + ), + ).toEqual({ + v: 1, + nonce: TEST_NONCE, + port: 43123, + projectPath: '/srv/ wiki ', + platform: 'darwin', + pathSeparator: '/', + protocolVersion: PROTOCOL_VERSION, + runtimeVersion: RUNTIME_VERSION, + capabilities: ['http', 'ws'], + owned: true, + }); + expect(parseRemoteReadyLine('ordinary log output', TEST_NONCE)).toBeNull(); + }); + + test('parses prerequisite markers without accepting unframed tool output', () => { + expect(parseRemoteMachineTest(`${testMarker('ok')}\n`, TEST_NONCE)).toBe('ok'); + expect(parseRemoteMachineTest(`${testMarker('platform-unsupported')}\n`, TEST_NONCE)).toBe( + 'platform-unsupported', + ); + expect(parseRemoteMachineTest(`${testMarker('node-too-old')}\n`, TEST_NONCE)).toBe( + 'node-too-old', + ); + expect(parseRemoteMachineTest(`${testMarker('git-too-old')}\n`, TEST_NONCE)).toBe( + 'git-too-old', + ); + expect(() => parseRemoteMachineTest('node v22.0.0', TEST_NONCE)).toThrow( + 'response was invalid', + ); + expect(() => parseRemoteMachineTest(`${testMarker('surprise')}\n`, TEST_NONCE)).toThrow( + 'response was invalid', + ); + }); + + test('requires exact protocol, runtime, and HTTP/WebSocket capabilities', () => { + expect(() => parseRemoteReadyLine(readyMarker({ protocolVersion: 999 }), TEST_NONCE)).toThrow( + 'incompatible', + ); + expect(() => + parseRemoteReadyLine(readyMarker({ runtimeVersion: '0.0.0-other' }), TEST_NONCE), + ).toThrow('runtime is incompatible'); + expect(() => + parseRemoteReadyLine(readyMarker({ runtimeVersion: ` ${RUNTIME_VERSION}` }), TEST_NONCE), + ).toThrow('runtime is incompatible'); + expect(() => parseRemoteReadyLine(readyMarker({ capabilities: ['http'] }), TEST_NONCE)).toThrow( + 'does not support', + ); + expect(() => parseRemoteReadyLine(readyMarker({ port: 0 }), TEST_NONCE)).toThrow( + 'port is invalid', + ); + expect(() => parseRemoteReadyLine(readyMarker({ platform: 'freebsd' }), TEST_NONCE)).toThrow( + 'platform is not supported', + ); + expect(() => parseRemoteReadyLine(readyMarker({ pathSeparator: '\\' }), TEST_NONCE)).toThrow( + 'separator is not supported', + ); + expect(parseRemoteReadyLine(readyMarker(), TEST_NONCE, PROTOCOL_VERSION, 32)).toBeNull(); + expect(parseRemoteReadyLine(readyMarker({ nonce: 'B'.repeat(43) }), TEST_NONCE)).toBeNull(); + }); + + test('parses bounded project inspections and structured companion errors', () => { + expect( + parseRemoteInspection( + `${REMOTE_INSPECT_MARKER}${JSON.stringify({ + v: 1, + nonce: TEST_NONCE, + selectedPath: '/srv/wiki/docs', + projectPath: '/srv/wiki', + initialized: true, + })}\n`, + TEST_NONCE, + ), + ).toEqual({ + selectedPath: '/srv/wiki/docs', + projectPath: '/srv/wiki', + initialized: true, + }); + expect( + parseRemoteErrorLine( + `${REMOTE_ERROR_MARKER}${JSON.stringify({ v: 1, nonce: TEST_NONCE, code: 'project-uninitialized' })}`, + TEST_NONCE, + ), + ).toMatchObject({ code: 'project-uninitialized' }); + expect(() => + parseRemoteErrorLine( + `${REMOTE_ERROR_MARKER}${JSON.stringify({ v: 1, nonce: TEST_NONCE, code: 'unknown' })}`, + TEST_NONCE, + ), + ).toThrow('was invalid'); + expect(() => + parseRemoteInspection( + `${REMOTE_INSPECT_MARKER}${JSON.stringify({ + v: 1, + nonce: TEST_NONCE, + selectedPath: 'relative', + projectPath: '/srv/wiki', + initialized: false, + })}\n`, + TEST_NONCE, + ), + ).toThrow('not an absolute path'); + }); +}); + +describe('bounded remote HTTP response reading', () => { + test('cancels a streamed body as soon as it exceeds the byte cap', async () => { + let cancelled = false; + const body = new ReadableStream({ + start(controller) { + controller.enqueue(new Uint8Array(600)); + controller.enqueue(new Uint8Array(600)); + }, + cancel() { + cancelled = true; + }, + }); + + await expect(readBoundedResponseText({ body }, 1024)).rejects.toMatchObject({ + code: 'output-limit', + }); + expect(cancelled).toBe(true); + }); +}); + +describe('remote terminal consent protocol', () => { + test('parses explicit allow and refusal markers', () => { + expect( + parseRemoteTerminalConsent( + `${REMOTE_TERMINAL_CONSENT_MARKER}${JSON.stringify({ v: 1, nonce: TEST_NONCE, allowed: true })}\n`, + TEST_NONCE, + ), + ).toBe(true); + expect( + parseRemoteTerminalConsent( + `${REMOTE_TERMINAL_CONSENT_MARKER}${JSON.stringify({ v: 1, nonce: TEST_NONCE, allowed: false })}\n`, + TEST_NONCE, + ), + ).toBe(false); + }); + + test('fails closed on a missing or malformed marker', () => { + expect(() => parseRemoteTerminalConsent('ordinary output', TEST_NONCE)).toThrow('was missing'); + expect(() => + parseRemoteTerminalConsent( + `${REMOTE_TERMINAL_CONSENT_MARKER}${JSON.stringify({ v: 1, nonce: TEST_NONCE, allowed: 'yes' })}\n`, + TEST_NONCE, + ), + ).toThrow('was invalid'); + }); +}); + +describe('RemoteProjectService', () => { + test('testMachine uses shell:false and returns renderer-safe connection errors', async () => { + const success = recordingSpawn((call) => { + queueMicrotask(() => { + call.child.writeStdout(`${testMarker('ok')}\n`); + call.child.close(0); + }); + }); + const service = new RemoteProjectService(serviceDeps(success.spawn)); + expect(await service.testMachine(MACHINE)).toEqual({ ok: true }); + expect(success.calls[0]?.file).toBe('/test/system-ssh'); + expect(success.calls[0]?.options).toEqual({ + shell: false, + stdio: ['ignore', 'pipe', 'pipe'], + windowsHide: true, + }); + + const denied = recordingSpawn((call) => { + queueMicrotask(() => { + call.child.writeStderr('Permission denied (publickey).\n'); + call.child.close(255); + }); + }); + const deniedService = new RemoteProjectService(serviceDeps(denied.spawn)); + expect(await deniedService.testMachine(MACHINE)).toEqual({ + ok: false, + error: 'SSH authentication failed.', + }); + }); + + test('installs the bundled companion over stdin when its content digest is absent', async () => { + const companion = new TextEncoder().encode('#!/usr/bin/env node\nconsole.log("remote");\n'); + const digest = createHash('sha256').update(companion).digest('hex'); + const spawned = recordingSpawn((call, index) => { + queueMicrotask(() => { + if (index === 0) call.child.writeStdout(`${testMarker('ok')}\n`); + else if (index === 1) call.child.writeStdout(`${companionMarker('missing')}\n`); + else call.child.writeStdout(`${companionMarker('installed')}\n`); + call.child.close(0); + }); + }); + const service = new RemoteProjectService( + serviceDeps(spawned.spawn, { + ensureRemoteCompanion: undefined, + loadRemoteCompanion: async () => companion, + }), + ); + + await expect(service.testMachine(MACHINE)).resolves.toEqual({ ok: true }); + expect(spawned.calls).toHaveLength(3); + expect(spawned.calls[1]?.args.at(-1)).toBe( + buildRemoteCompanionProbeCommand(digest, TEST_NONCE), + ); + expect(spawned.calls[2]?.args.at(-1)).toBe( + buildRemoteCompanionInstallCommand(digest, companion.byteLength, TEST_NONCE), + ); + expect(spawned.calls[2]?.options.stdio).toEqual(['pipe', 'pipe', 'pipe']); + expect(spawned.calls[2]?.child.stdinPayloads).toEqual([companion]); + }); + + test('surfaces a writable-home action when companion installation is refused', async () => { + const companion = new TextEncoder().encode('remote companion'); + const spawned = recordingSpawn((call, index) => { + queueMicrotask(() => { + if (index === 0) call.child.writeStdout(`${testMarker('ok')}\n`); + else if (index === 1) call.child.writeStdout(`${companionMarker('missing')}\n`); + else call.child.writeStderr('OK_REMOTE_COMPANION_INSTALL_ERROR\n'); + call.child.close(index === 2 ? 1 : 0); + }); + }); + const service = new RemoteProjectService( + serviceDeps(spawned.spawn, { + ensureRemoteCompanion: undefined, + loadRemoteCompanion: async () => companion, + }), + ); + + await expect(service.testMachine(MACHINE)).resolves.toEqual({ + ok: false, + error: + 'OpenKnowledge could not install remote support. Ensure the SSH home is writable and `~/.ok` is owned by that user, is not a symlink, and is not group- or world-writable.', + }); + }); + + test('coalesces concurrent companion installs for the same saved machine', async () => { + const companion = new TextEncoder().encode('remote companion'); + let companionProbes = 0; + let installs = 0; + const spawned = recordingSpawn((call) => { + const command = call.args.at(-1) ?? ''; + if (command.includes(REMOTE_TEST_MARKER)) { + queueMicrotask(() => { + call.child.writeStdout(`${testMarker('ok')}\n`); + call.child.close(0); + }); + return; + } + if (call.options.stdio[0] === 'ignore') { + companionProbes += 1; + setTimeout(() => { + call.child.writeStdout(`${companionMarker('missing')}\n`); + call.child.close(0); + }, 1); + return; + } + installs += 1; + queueMicrotask(() => { + call.child.writeStdout(`${companionMarker('installed')}\n`); + call.child.close(0); + }); + }); + const service = new RemoteProjectService( + serviceDeps(spawned.spawn, { + ensureRemoteCompanion: undefined, + loadRemoteCompanion: async () => companion, + }), + ); + + await expect( + Promise.all([service.testMachine(MACHINE), service.testMachine(MACHINE)]), + ).resolves.toEqual([{ ok: true }, { ok: true }]); + expect(companionProbes).toBe(1); + expect(installs).toBe(1); + }); + + test('testMachine reports actionable runtime prerequisite failures', async () => { + const cases = [ + [ + 'platform-unsupported', + 'OpenKnowledge remote projects support macOS and Linux SSH machines.', + ], + ['node-missing', 'Install Node.js 24 or newer on the SSH machine.'], + ['node-too-old', 'Update Node.js on the SSH machine to version 24 or newer.'], + ['git-missing', 'Install Git 2.31.0 or newer on the SSH machine.'], + ['git-too-old', 'Update Git on the SSH machine to version 2.31.0 or newer.'], + ] as const; + for (const [status, error] of cases) { + const spawned = recordingSpawn((call) => { + queueMicrotask(() => { + call.child.writeStdout(`${testMarker(status)}\n`); + call.child.close(0); + }); + }); + const service = new RemoteProjectService(serviceDeps(spawned.spawn)); + expect(await service.testMachine(MACHINE)).toEqual({ ok: false, error }); + } + }); + + test('testMachine inspects the effective OpenSSH config with bounded safe argv', async () => { + const spawned = recordingSpawn((call, index) => { + queueMicrotask(() => { + if (index === 0) { + call.child.writeStdout( + 'host build-box\nhostname 10.0.0.12\nuser developer\nport 2222\nproxyjump none\n', + ); + } else { + call.child.writeStdout(`${testMarker('ok')}\n`); + } + call.child.close(0); + }); + }); + const service = new RemoteProjectService( + serviceDeps(spawned.spawn, { inspectTunnelConfig: undefined }), + ); + + expect(await service.testMachine(MACHINE)).toEqual({ ok: true }); + expect(spawned.calls).toHaveLength(2); + expect(spawned.calls[0]?.args).toContain('-G'); + expect(spawned.calls[0]?.args).toContain('ControlPath=none'); + expect(spawned.calls[1]?.args.at(-1)).toContain(REMOTE_TEST_MARKER); + }); + + test('isCommandAvailable uses a validated fixed command probe', async () => { + const available = recordingSpawn((call, index) => { + queueMicrotask(() => { + call.child.writeStdout(`${commandMarker(index === 0)}\n`); + call.child.close(0); + }); + }); + const service = new RemoteProjectService(serviceDeps(available.spawn)); + expect(await service.isCommandAvailable(MACHINE, 'claude')).toBe(true); + expect(available.calls[0]?.args.at(-1)).toBe( + buildIsCommandAvailableCommand('claude', TEST_NONCE), + ); + expect(await service.isCommandAvailable(MACHINE, 'codex')).toBe(false); + + await expect(service.isCommandAvailable(MACHINE, 'claude; rm -rf /')).rejects.toMatchObject({ + code: 'invalid-response', + }); + }); + + test('refuses remote tool probes after the effective SSH endpoint drifts', async () => { + let fingerprint = 'session-fingerprint'; + const spawned = recordingSpawn((call) => { + queueMicrotask(() => { + call.child.writeStdout(`${commandMarker(true)}\n`); + call.child.close(0); + }); + }); + const service = new RemoteProjectService( + serviceDeps(spawned.spawn, { + inspectTunnelConfig: async () => fingerprint, + }), + ); + + await expect( + service.isCommandAvailable(MACHINE, 'claude', 'session-fingerprint'), + ).resolves.toBe(true); + fingerprint = 'changed-fingerprint'; + await expect( + service.isCommandAvailable(MACHINE, 'claude', 'session-fingerprint'), + ).rejects.toMatchObject({ + code: 'invalid-machine', + message: expect.stringContaining('Close and reopen the project'), + }); + expect(spawned.calls).toHaveLength(1); + }); + + test('listDirectories validates and translates the canonical wire response', async () => { + const spawned = recordingSpawn((call) => { + queueMicrotask(() => { + call.child.writeStdout( + `${directoryMarker({ + canonicalPath: '/home/dev/ wiki ', + parentPath: '/home/dev', + directories: [ + { name: ' alpha ', path: '/home/dev/ wiki / alpha ' }, + { name: 'beta', path: '/home/dev/ wiki /beta' }, + ], + })}\n`, + ); + call.child.close(0); + }); + }); + const service = new RemoteProjectService(serviceDeps(spawned.spawn)); + await expect(service.listDirectories(MACHINE, '~/wiki')).resolves.toEqual({ + path: '/home/dev/ wiki ', + parentPath: '/home/dev', + directories: [ + { name: ' alpha ', path: '/home/dev/ wiki / alpha ' }, + { name: 'beta', path: '/home/dev/ wiki /beta' }, + ], + }); + expect(spawned.calls[0]?.args.at(-1)).not.toContain('~/wiki'); + }); + + test('decodes UTF-8 markers split inside a multibyte character', async () => { + const marker = Buffer.from( + `${directoryMarker({ + canonicalPath: '/home/dev/café', + parentPath: '/home/dev', + directories: [{ name: '研究', path: '/home/dev/café/研究' }], + })}\n`, + 'utf8', + ); + const splitAt = marker.indexOf(Buffer.from('é')) + 1; + expect(splitAt).toBeGreaterThan(0); + const spawned = recordingSpawn((call) => { + queueMicrotask(() => { + call.child.writeStdout(marker.subarray(0, splitAt)); + call.child.writeStdout(marker.subarray(splitAt)); + call.child.close(0); + }); + }); + const service = new RemoteProjectService(serviceDeps(spawned.spawn)); + await expect(service.listDirectories(MACHINE, '/home/dev/café')).resolves.toEqual({ + path: '/home/dev/café', + parentPath: '/home/dev', + directories: [{ name: '研究', path: '/home/dev/café/研究' }], + }); + }); + + test('allows directory payloads above the smaller readiness-marker cap', async () => { + const directories = Array.from({ length: 500 }, (_, index) => ({ + name: `directory-${index.toString().padStart(4, '0')}`, + path: `/srv/wiki/directory-${index.toString().padStart(4, '0')}`, + })); + const marker = `${directoryMarker({ + canonicalPath: '/srv/wiki', + parentPath: '/srv', + directories, + })}\n`; + expect(Buffer.byteLength(marker)).toBeGreaterThan(16 * 1024); + const spawned = recordingSpawn((call) => { + queueMicrotask(() => { + call.child.writeStdout(marker); + call.child.close(0); + }); + }); + const service = new RemoteProjectService(serviceDeps(spawned.spawn)); + const result = await service.listDirectories(MACHINE, '/srv/wiki'); + expect(result.directories).toHaveLength(500); + }); + + test('surfaces the remote directory scan limit explicitly', async () => { + const spawned = recordingSpawn((call) => { + queueMicrotask(() => { + call.child.writeStdout(`${directoryMarker({ error: 'too-many' })}\n`); + call.child.close(0); + }); + }); + const service = new RemoteProjectService(serviceDeps(spawned.spawn)); + + await expect(service.listDirectories(MACHINE, '/srv')).rejects.toMatchObject({ + code: 'output-limit', + message: 'The remote folder contains too many entries.', + }); + }); + + test('surfaces remote directory read failures without forwarding path details', async () => { + const spawned = recordingSpawn((call) => { + queueMicrotask(() => { + call.child.writeStdout(`${directoryMarker({ error: 'failed' })}\n`); + call.child.close(0); + }); + }); + const service = new RemoteProjectService(serviceDeps(spawned.spawn)); + + await expect(service.listDirectories(MACHINE, '/private/project')).rejects.toMatchObject({ + code: 'ssh-failed', + message: 'OpenKnowledge could not read that remote folder.', + }); + }); + + test('inspects a project without initializing it', async () => { + const spawned = recordingProjectSpawn((call) => { + queueMicrotask(() => { + call.child.writeStdout( + `${REMOTE_INSPECT_MARKER}${JSON.stringify({ + v: 1, + nonce: TEST_NONCE, + selectedPath: '/srv/wiki', + projectPath: '/srv/wiki', + initialized: false, + })}\n`, + ); + call.child.close(0); + }); + }); + const service = new RemoteProjectService(serviceDeps(spawned.spawn)); + + await expect(service.inspectProject(MACHINE, '/srv/wiki')).resolves.toEqual({ + selectedPath: '/srv/wiki', + projectPath: '/srv/wiki', + initialized: false, + }); + expect(spawned.calls).toHaveLength(1); + expect(spawned.calls[0]?.args.at(-1)).toBe( + buildRemoteInspectCommand('/srv/wiki', TEST_COMPANION_DIGEST, TEST_NONCE), + ); + }); + + test('starts the remote server and independently closes its tunnel and server owner', async () => { + const spawned = recordingProjectSpawn((call, index) => { + if (index === 0) { + queueMicrotask(() => call.child.writeStdout(`${readyMarker()}\n`)); + } else if (index === 1) { + emitTunnelReady(call.child); + } + }); + const fetch = mock(async (url: string) => ({ + ok: true, + status: 200, + text: async () => JSON.stringify({ collabUrl: 'ws://localhost:43123/collab', port: 43123 }), + url, + })); + const service = new RemoteProjectService( + serviceDeps(spawned.spawn, { fetch, shutdownGraceMs: 5 }), + ); + const session = await service.startProject(MACHINE, '~/wiki', { initialize: false }); + + expect(spawned.calls).toHaveLength(2); + expect(spawned.calls[0]?.args.at(-1)).toContain('remote-companion.mjs'); + expect(spawned.calls[0]?.args.at(-1)).toContain('--nonce'); + expect(spawned.calls[0]?.args.at(-1)).not.toContain('--initialize'); + expect(spawned.calls[1]?.args).toContain('127.0.0.1:45123:127.0.0.1:43123'); + expect(spawned.calls[0]?.options.stdio).toEqual(['pipe', 'pipe', 'pipe']); + expect(spawned.calls[1]?.options.stdio).toEqual(['pipe', 'pipe', 'pipe']); + expect(fetch).toHaveBeenCalledWith( + 'http://127.0.0.1:45123/api/config', + expect.objectContaining({ method: 'GET', redirect: 'error' }), + ); + expect(session).toMatchObject({ + localPort: 45123, + apiOrigin: 'http://127.0.0.1:45123', + collabUrl: 'ws://127.0.0.1:45123/collab', + projectPath: '/srv/wiki', + platform: 'linux', + pathSeparator: '/', + owned: true, + }); + + session.closeTunnel(); + session.closeTunnel(); + expect(spawned.calls[0]?.child.stdin.end).not.toHaveBeenCalled(); + expect(spawned.calls[1]?.child.stdin.end).toHaveBeenCalledTimes(1); + expect(spawned.calls[0]?.child.kill).not.toHaveBeenCalled(); + expect(spawned.calls[1]?.child.kill).not.toHaveBeenCalled(); + await new Promise((resolve) => setTimeout(resolve, 10)); + expect(spawned.calls[1]?.child.lifecycle).toEqual(['stdin:end', 'kill']); + expect(spawned.calls[1]?.child.kill).toHaveBeenCalledTimes(1); + expect(spawned.calls[0]?.child.kill).not.toHaveBeenCalled(); + + session.closeServer(); + session.closeServer(); + expect(spawned.calls[0]?.child.stdin.end).toHaveBeenCalledTimes(1); + expect(spawned.calls[1]?.child.stdin.end).toHaveBeenCalledTimes(1); + await new Promise((resolve) => setTimeout(resolve, 10)); + expect(spawned.calls[0]?.child.lifecycle).toEqual(['stdin:end', 'kill']); + expect(spawned.calls[0]?.child.kill).toHaveBeenCalledTimes(1); + expect(spawned.calls[1]?.child.kill).toHaveBeenCalledTimes(1); + + session.close(); + session.close(); + expect(spawned.calls[0]?.child.stdin.end).toHaveBeenCalledTimes(1); + expect(spawned.calls[1]?.child.stdin.end).toHaveBeenCalledTimes(1); + }); + + test('does not poll HTTP until SSH confirms that its explicit forward is bound', async () => { + const spawned = recordingProjectSpawn((call, index) => { + if (index === 0) queueMicrotask(() => call.child.writeStdout(`${readyMarker()}\n`)); + if (index === 1) queueMicrotask(() => call.child.close(255)); + }); + const fetch = mock(async () => ({ + ok: true, + status: 200, + text: async () => JSON.stringify({ collabUrl: null, port: 43123 }), + })); + const service = new RemoteProjectService(serviceDeps(spawned.spawn, { fetch })); + + await expect( + service.startProject(MACHINE, '/srv/wiki', { initialize: false }), + ).rejects.toMatchObject({ + code: 'tunnel-failed', + }); + expect(fetch).not.toHaveBeenCalled(); + expect(spawned.calls[0]?.child.kill).toHaveBeenCalled(); + expect(spawned.calls[1]?.child.kill).toHaveBeenCalled(); + }); + + test('accepts only the remote server port announced by the readiness frame', async () => { + const spawned = recordingProjectSpawn((call, index) => { + if (index === 0) queueMicrotask(() => call.child.writeStdout(`${readyMarker()}\n`)); + if (index === 1) emitTunnelReady(call.child); + }); + const service = new RemoteProjectService( + serviceDeps(spawned.spawn, { + fetch: async () => ({ + ok: true, + status: 200, + text: async () => JSON.stringify({ collabUrl: null, port: 43124 }), + }), + tunnelReadyTimeoutMs: 2, + pollIntervalMs: 1, + }), + ); + + await expect( + service.startProject(MACHINE, '/srv/wiki', { initialize: false }), + ).rejects.toMatchObject({ + code: 'invalid-response', + }); + }); + + test('fails immediately on non-successful or malformed remote API responses', async () => { + for (const response of [ + { ok: false, status: 503, text: async () => '' }, + { ok: true, status: 200, text: async () => '{bad json' }, + ]) { + const spawned = recordingProjectSpawn((call, index) => { + if (index === 0) queueMicrotask(() => call.child.writeStdout(`${readyMarker()}\n`)); + if (index === 1) emitTunnelReady(call.child); + }); + const fetch = mock(async () => response); + const service = new RemoteProjectService(serviceDeps(spawned.spawn, { fetch })); + + await expect( + service.startProject(MACHINE, '/srv/wiki', { initialize: false }), + ).rejects.toMatchObject({ + code: 'invalid-response', + }); + expect(fetch).toHaveBeenCalledTimes(1); + } + }); + + test('decodes readiness split inside a multibyte project path', async () => { + const marker = Buffer.from(`${readyMarker({ projectPath: '/srv/café ' })}\n`, 'utf8'); + const splitAt = marker.indexOf(Buffer.from('é')) + 1; + const spawned = recordingProjectSpawn((call, index) => { + if (index === 0) { + queueMicrotask(() => { + call.child.writeStdout(marker.subarray(0, splitAt)); + call.child.writeStdout(marker.subarray(splitAt)); + }); + } else if (index === 1) { + emitTunnelReady(call.child); + } + }); + const service = new RemoteProjectService(serviceDeps(spawned.spawn)); + const session = await service.startProject(MACHINE, '/srv/café ', { initialize: false }); + expect(session.projectPath).toBe('/srv/café '); + session.close(); + }); + + test('does not force-kill an owned server that exits after stdin EOF', async () => { + const spawned = recordingProjectSpawn((call, index) => { + if (index === 0) { + call.child.onStdinEnd = () => queueMicrotask(() => call.child.close(0)); + queueMicrotask(() => call.child.writeStdout(`${readyMarker()}\n`)); + } else { + call.child.onStdinEnd = () => queueMicrotask(() => call.child.close(0)); + emitTunnelReady(call.child); + } + }); + const service = new RemoteProjectService(serviceDeps(spawned.spawn, { shutdownGraceMs: 5 })); + const session = await service.startProject(MACHINE, '/srv/wiki', { initialize: false }); + session.close(); + await Promise.resolve(); + await Promise.resolve(); + expect(spawned.calls[0]?.child.stdin.end).toHaveBeenCalledTimes(1); + expect(spawned.calls[0]?.child.kill).not.toHaveBeenCalled(); + expect(spawned.calls[1]?.child.stdin.end).toHaveBeenCalledTimes(1); + expect(spawned.calls[1]?.child.kill).not.toHaveBeenCalled(); + }); + + test('rejects an unowned readiness frame', async () => { + const spawned = recordingProjectSpawn((call, index) => { + if (index === 0) { + queueMicrotask(() => call.child.writeStdout(`${readyMarker({ owned: false })}\n`)); + } else if (index === 1) { + emitTunnelReady(call.child); + } + }); + const service = new RemoteProjectService(serviceDeps(spawned.spawn)); + await expect( + service.startProject(MACHINE, '/srv/wiki', { initialize: false }), + ).rejects.toMatchObject({ code: 'invalid-response' }); + expect(spawned.calls).toHaveLength(1); + }); + + test('kills both SSH children when the tunneled API never becomes ready', async () => { + const spawned = recordingProjectSpawn((call, index) => { + if (index === 0) queueMicrotask(() => call.child.writeStdout(`${readyMarker()}\n`)); + if (index === 1) emitTunnelReady(call.child); + }); + const service = new RemoteProjectService( + serviceDeps(spawned.spawn, { + fetch: async () => { + throw new TypeError('not listening'); + }, + tunnelReadyTimeoutMs: 2, + pollIntervalMs: 1, + }), + ); + await expect( + service.startProject(MACHINE, '/srv/wiki', { initialize: false }), + ).rejects.toMatchObject({ + code: 'timeout', + }); + expect(spawned.calls[0]?.child.kill).toHaveBeenCalled(); + expect(spawned.calls[1]?.child.kill).toHaveBeenCalled(); + }); + + test('checks typed prerequisites before installing or launching the companion', async () => { + const cases = [ + [ + 'platform-unsupported', + 'unsupported-platform', + 'OpenKnowledge remote projects support macOS and Linux SSH machines.', + ], + [ + 'node-too-old', + 'prerequisite-outdated', + 'Update Node.js on the SSH machine to version 24 or newer.', + ], + ['git-missing', 'prerequisite-missing', 'Install Git 2.31.0 or newer on the SSH machine.'], + [ + 'git-too-old', + 'prerequisite-outdated', + 'Update Git on the SSH machine to version 2.31.0 or newer.', + ], + ] as const; + for (const [status, code, message] of cases) { + const spawned = recordingSpawn((call) => { + queueMicrotask(() => { + call.child.writeStdout(`${testMarker(status)}\n`); + call.child.close(0); + }); + }); + const service = new RemoteProjectService(serviceDeps(spawned.spawn)); + await expect( + service.startProject(MACHINE, '/srv/wiki', { initialize: false }), + ).rejects.toMatchObject({ + code, + message, + }); + expect(spawned.calls).toHaveLength(1); + expect(spawned.calls[0]?.args.at(-1)).toContain(REMOTE_TEST_MARKER); + } + }); + + test('surfaces only bounded structured companion startup errors', async () => { + const cases = [ + ['project-uninitialized', 'project-uninitialized'], + ['project-initialize-failed', 'project-initialize-failed'], + ['config-invalid', 'config-invalid'], + ['content-dir-outside-project', 'content-dir-outside-project'], + ['startup-failed', 'startup-failed'], + ] as const; + for (const [wireCode, code] of cases) { + const spawned = recordingProjectSpawn((call) => { + queueMicrotask(() => { + call.child.writeStdout( + `${REMOTE_ERROR_MARKER}${JSON.stringify({ v: 1, nonce: TEST_NONCE, code: wireCode })}\n`, + ); + call.child.close(1); + }); + }); + const service = new RemoteProjectService(serviceDeps(spawned.spawn)); + await expect( + service.startProject(MACHINE, '/srv/wiki', { initialize: false }), + ).rejects.toMatchObject({ + code, + }); + expect(spawned.calls).toHaveLength(1); + expect(spawned.calls[0]?.child.kill).toHaveBeenCalled(); + } + }); + + test('bounds child output and terminates an over-producing command', async () => { + const spawned = recordingSpawn((call) => { + queueMicrotask(() => call.child.writeStdout('x'.repeat(1025))); + }); + const service = new RemoteProjectService( + serviceDeps(spawned.spawn, { + maxOutputBytes: 1024, + }), + ); + await expect(service.listDirectories(MACHINE, '/srv/wiki')).rejects.toMatchObject({ + code: 'output-limit', + }); + expect(spawned.calls[0]?.child.kill).toHaveBeenCalled(); + }); +}); diff --git a/packages/desktop/tests/main/state-store.test.ts b/packages/desktop/tests/main/state-store.test.ts index 8b001c415..3731f8a55 100644 --- a/packages/desktop/tests/main/state-store.test.ts +++ b/packages/desktop/tests/main/state-store.test.ts @@ -4,14 +4,17 @@ import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { addRecentProject, + addRecentRemoteProject, annotateMissing, emptyState, getProjectSessionState, type PersistedWindowBounds, parseAppState, removeRecentProject, + removeSshMachine, type SaveAppStateFs, saveAppStateToDir, + saveSshMachine, setLastUsedProjectParent, setProjectSessionState, setProjectWindowBounds, @@ -182,6 +185,200 @@ describe('state-store (recent projects + LRU)', () => { }); }); +describe('state-store (SSH machines and remote recents)', () => { + const machine = { + id: 'build-box', + name: 'Build box', + host: 'dev@example.com', + port: 2222, + } as const; + + test('saves a machine and adds a machine-scoped recent project', () => { + const withMachine = saveSshMachine(emptyState(), machine); + const next = addRecentRemoteProject( + withMachine, + 'ssh:build-box:%2Fsrv%2Fdocs', + 'docs', + machine, + '/srv/docs', + ); + + expect(next.sshMachines).toEqual([machine]); + expect(next.lastOpenedProject).toBe('ssh:build-box:%2Fsrv%2Fdocs'); + expect(next.recentProjects[0]).toMatchObject({ + path: 'ssh:build-box:%2Fsrv%2Fdocs', + name: 'docs', + remote: { + machineId: 'build-box', + machineName: 'Build box', + path: '/srv/docs', + }, + }); + }); + + test('remote recents are not marked missing by local filesystem probes', () => { + const state = addRecentRemoteProject( + saveSshMachine(emptyState(), machine), + 'ssh:build-box:%2Fsrv%2Fdocs', + 'docs', + machine, + '/srv/docs', + ); + const exists = mock(() => false); + + expect(annotateMissing(state, exists)[0]?.missing).toBe(false); + expect(exists).not.toHaveBeenCalled(); + }); + + test('renaming a machine updates the label stored on dependent recents', () => { + let state = saveSshMachine(emptyState(), machine); + state = addRecentRemoteProject( + state, + 'ssh:build-box:%2Fsrv%2Fdocs', + 'docs', + machine, + '/srv/docs', + ); + + const next = saveSshMachine(state, { ...machine, name: 'GPU host' }); + expect(next.recentProjects[0]?.remote?.machineName).toBe('GPU host'); + }); + + test('refuses to rebind an existing machine identity to another endpoint', () => { + const state = saveSshMachine(emptyState(), machine); + + expect(() => saveSshMachine(state, { ...machine, host: 'other@example.com' })).toThrow( + 'An SSH machine endpoint cannot be changed in place. Add a new machine.', + ); + expect(() => saveSshMachine(state, { ...machine, port: 22 })).toThrow( + 'An SSH machine endpoint cannot be changed in place. Add a new machine.', + ); + }); + + test('removing a machine removes every dependent persisted project record', () => { + const key = 'ssh:build-box:%2Fsrv%2Fdocs'; + const orphanKey = 'ssh:build-box:%2Fsrv%2Forphan'; + const similarlyNamedMachineKey = 'ssh:build-box-extra:%2Fsrv%2Fdocs'; + const localKey = '/tmp/local'; + let state = saveSshMachine(emptyState(), machine); + state = addRecentRemoteProject(state, key, 'docs', machine, '/srv/docs'); + for (const projectKey of [key, orphanKey, similarlyNamedMachineKey, localKey]) { + state = setProjectSessionState(state, projectKey, { + openTabs: ['README'], + pinnedTabIds: [], + activeDocName: 'README', + activeTabId: 'README', + updatedAt: '2026-07-13T00:00:00Z', + }); + state = setProjectWindowBounds(state, projectKey, { + x: 10, + y: 10, + width: 800, + height: 600, + isMaximized: false, + isFullScreen: false, + }); + } + state = { + ...state, + pendingWindowRestore: [key, orphanKey, similarlyNamedMachineKey, localKey], + }; + + const next = removeSshMachine(state, machine.id); + expect(next.sshMachines).toEqual([]); + expect(next.recentProjects).toEqual([]); + expect(next.lastOpenedProject).toBeNull(); + expect(next.projectSessions[key]).toBeUndefined(); + expect(next.projectSessions[orphanKey]).toBeUndefined(); + expect(next.projectSessions[similarlyNamedMachineKey]).toBeDefined(); + expect(next.projectSessions[localKey]).toBeDefined(); + expect(next.projectWindowBounds[key]).toBeUndefined(); + expect(next.projectWindowBounds[orphanKey]).toBeUndefined(); + expect(next.projectWindowBounds[similarlyNamedMachineKey]).toBeDefined(); + expect(next.projectWindowBounds[localKey]).toBeDefined(); + expect(next.pendingWindowRestore).toEqual([similarlyNamedMachineKey, localKey]); + }); + + test('session cleanup scopes an encoded machine id exactly', () => { + const encodedMachine = { ...machine, id: 'build:box' }; + const encodedPrefix = `ssh:${encodeURIComponent(encodedMachine.id)}:`; + const ownKey = `${encodedPrefix}%2Fsrv%2Fdocs`; + const unencodedLookalike = 'ssh:build:box:%2Fsrv%2Fdocs'; + let state = saveSshMachine(emptyState(), encodedMachine); + for (const projectKey of [ownKey, unencodedLookalike]) { + state = setProjectSessionState(state, projectKey, { + openTabs: ['README'], + pinnedTabIds: [], + activeDocName: 'README', + activeTabId: 'README', + updatedAt: '2026-07-13T00:00:00Z', + }); + } + + const next = removeSshMachine(state, encodedMachine.id); + + expect(next.projectSessions[ownKey]).toBeUndefined(); + expect(next.projectSessions[unencodedLookalike]).toBeDefined(); + }); + + test('parseAppState defensively round-trips valid SSH state', () => { + const state = addRecentRemoteProject( + saveSshMachine(emptyState(), machine), + 'ssh:build-box:%2Fsrv%2Fdocs', + 'docs', + machine, + '/srv/docs', + ); + const parsed = parseAppState(JSON.parse(JSON.stringify(state))); + + expect(parsed?.sshMachines).toEqual([machine]); + expect(parsed?.recentProjects[0]?.remote).toEqual({ + machineId: 'build-box', + machineName: 'Build box', + path: '/srv/docs', + }); + }); + + test('parseAppState rejects malformed, unsafe, and duplicate SSH machine state', () => { + const invalidMachines = [ + { ...machine, name: 'duplicate' }, + { id: 'bad-port', name: 'Bad', host: 'bad', port: 70_000 }, + { id: 'string-port', name: 'String port', host: 'bad', port: '22' }, + { id: 'unsafe-host', name: 'Unsafe', host: '-oProxyCommand=oops' }, + { id: 'spaced-host', name: 'Spaced', host: 'dev @example.com' }, + { id: 'trimmed-host', name: 'Trimmed', host: ' example.com ' }, + { id: 'missing-host', name: 'Missing' }, + ]; + for (const invalid of invalidMachines) { + expect(parseAppState({ recentProjects: [], sshMachines: [machine, invalid] })).toBeNull(); + } + expect(parseAppState({ recentProjects: [], sshMachines: 'invalid' })).toBeNull(); + }); + + test('parseAppState rejects remote recents that are stale, malformed, or relabeled', () => { + const validRemote = { + machineId: machine.id, + machineName: machine.name, + path: '/srv/docs', + }; + const validRecent = { + path: 'ssh:build-box:%2Fsrv%2Fdocs', + name: 'docs', + lastOpenedAt: '2026-07-13T00:00:00Z', + remote: validRemote, + }; + for (const recent of [ + { ...validRecent, path: 'ssh:other:%2Fsrv%2Fdocs' }, + { ...validRecent, remote: { ...validRemote, machineId: 'missing' } }, + { ...validRecent, remote: { ...validRemote, machineName: 'Stale label' } }, + { ...validRecent, remote: { ...validRemote, path: '../outside' } }, + { ...validRecent, remote: null }, + ]) { + expect(parseAppState({ recentProjects: [recent], sshMachines: [machine] })).toBeNull(); + } + }); +}); + describe('state-store (gitRemoteUrl field on RecentProject)', () => { test('addRecentProject persists the optional gitRemoteUrl when provided', () => { const next = addRecentProject( diff --git a/packages/desktop/tests/main/terminal-manager.test.ts b/packages/desktop/tests/main/terminal-manager.test.ts index 600e0f37d..381162c12 100644 --- a/packages/desktop/tests/main/terminal-manager.test.ts +++ b/packages/desktop/tests/main/terminal-manager.test.ts @@ -149,6 +149,31 @@ describe('createTerminalManager — create', () => { expect(h.forked).toHaveLength(0); }); + test('forwards a main-owned executable override to the PTY host', () => { + const h = makeManager(); + h.mgr.create({ + windowId: 1, + webContents: makeWebContents(), + projectRoot: '/Users/me', + cols: 80, + rows: 24, + executable: { + file: '/usr/bin/ssh', + args: ['-tt', '--', 'devbox', 'remote-command'], + }, + }); + + expect(h.forked[0]?.posted).toContainEqual({ + type: 'create', + ptyId: 'pty-1', + cwd: '/Users/me', + cols: 80, + rows: 24, + executable: '/usr/bin/ssh', + args: ['-tt', '--', 'devbox', 'remote-command'], + }); + }); + test('a second create for the same window reuses the host with a fresh ptyId', () => { const h = makeManager(); const wc = makeWebContents(); diff --git a/packages/desktop/tests/main/terminal-window-attach-survival.test.ts b/packages/desktop/tests/main/terminal-window-attach-survival.test.ts index 033da9f05..8579d5fdd 100644 --- a/packages/desktop/tests/main/terminal-window-attach-survival.test.ts +++ b/packages/desktop/tests/main/terminal-window-attach-survival.test.ts @@ -207,11 +207,15 @@ describe('terminal window PTY survives owner-server teardown (seam 6 / FR4 / D2) // inherits the collab/api argv). It is registered in the terminalWindows // registry — deliberately absent from windowsByPath — so ok:pty:create // resolves its cwd from the registry. - registerTerminalWindow(TERM_WIN_ID, { - projectRoot: PROJECT, - collabUrl: `ws://localhost:${ownerCtx.port}/collab`, - apiOrigin: ownerCtx.apiOrigin, - }); + registerTerminalWindow( + TERM_WIN_ID, + { + projectRoot: PROJECT, + collabUrl: `ws://localhost:${ownerCtx.port}/collab`, + apiOrigin: ownerCtx.apiOrigin, + }, + { window: {}, reapPtys: () => {} }, + ); const cwd = resolvePtyProjectRoot({ editorProjectPath: null, terminalWindow: getTerminalWindowContext(TERM_WIN_ID), diff --git a/packages/desktop/tests/main/terminal-window-pty.test.ts b/packages/desktop/tests/main/terminal-window-pty.test.ts index dd36fa584..6cc0df7f9 100644 --- a/packages/desktop/tests/main/terminal-window-pty.test.ts +++ b/packages/desktop/tests/main/terminal-window-pty.test.ts @@ -69,6 +69,7 @@ const WIN_BOUND = 80_001; const WIN_LESS = 80_002; const WIN_A = 80_003; const WIN_B = 80_004; +const NOOP_LIFECYCLE = { window: {}, reapPtys: () => {} }; afterEach(() => { for (const id of [WIN_BOUND, WIN_LESS, WIN_A, WIN_B]) unregisterTerminalWindow(id); @@ -76,7 +77,7 @@ afterEach(() => { describe('terminal window ok:pty:create cwd resolution (seam 7 / D10)', () => { test('a project-bound terminal window spawns a live PTY at its registered project root', () => { - registerTerminalWindow(WIN_BOUND, { projectRoot: PROJECT }); + registerTerminalWindow(WIN_BOUND, { projectRoot: PROJECT }, NOOP_LIFECYCLE); const cwd = resolveTerminalWindowCwd(WIN_BOUND); expect(cwd).toBe(PROJECT); @@ -102,7 +103,7 @@ describe('terminal window ok:pty:create cwd resolution (seam 7 / D10)', () => { }); test('a project-less terminal window spawns a live PTY at the home directory (never null)', () => { - registerTerminalWindow(WIN_LESS, { projectRoot: null }); + registerTerminalWindow(WIN_LESS, { projectRoot: null }, NOOP_LIFECYCLE); const cwd = resolveTerminalWindowCwd(WIN_LESS); expect(cwd).toBe(HOME); @@ -127,8 +128,8 @@ describe('terminal window ok:pty:create cwd resolution (seam 7 / D10)', () => { }); test('multiple terminal windows for the same project each fork their own PTY host', () => { - registerTerminalWindow(WIN_A, { projectRoot: PROJECT }); - registerTerminalWindow(WIN_B, { projectRoot: PROJECT }); + registerTerminalWindow(WIN_A, { projectRoot: PROJECT }, NOOP_LIFECYCLE); + registerTerminalWindow(WIN_B, { projectRoot: PROJECT }, NOOP_LIFECYCLE); const { mgr, forked } = makeManager(); const a = mgr.create({ diff --git a/packages/desktop/tests/main/window-manager.test.ts b/packages/desktop/tests/main/window-manager.test.ts index 2137314fe..bf116574d 100644 --- a/packages/desktop/tests/main/window-manager.test.ts +++ b/packages/desktop/tests/main/window-manager.test.ts @@ -95,6 +95,9 @@ function makeWindow(opts?: { minimized?: boolean; focused?: boolean }): BrowserW if (event === 'dom-ready') domReadyHandler = cb; else if (event === 'did-finish-load') didFinishLoadHandler = cb; }), + executeJavaScript: mock(async () => {}), + setWindowOpenHandler: mock(() => {}), + on: mock(() => {}), }, loadFile: mock(() => Promise.resolve()), loadURL: mock(() => Promise.resolve()), @@ -107,6 +110,16 @@ function makeWindow(opts?: { minimized?: boolean; focused?: boolean }): BrowserW }; } +function releaseRemoteOrigin(context: { + closeRemoteSession?: () => void; + retireRemoteTunnel?: () => void; +}): void { + const closeSession = context.closeRemoteSession; + context.closeRemoteSession = undefined; + context.retireRemoteTunnel = undefined; + closeSession?.(); +} + interface ShowGateRegistration { window: BrowserWindowLike; kind: 'editor' | 'navigator'; @@ -246,6 +259,508 @@ describe('WindowManager', () => { expect(env2.createWindowOpts[0]?.additionalArguments).not.toContain('--ok-fresh-create=1'); }); + test('createRemoteProjectWindow injects remote metadata and closes its session with the window', async () => { + const wm = new WindowManager(env.deps); + const closeSession = mock(() => {}); + const ctx = await wm.createRemoteProjectWindow({ + projectKey: 'ssh:build-box:%2Fsrv%2Fwiki', + projectName: 'wiki', + remote: { + kind: 'ssh', + machineId: 'build-box', + machineName: 'Build box', + path: '/srv/wiki', + platform: 'linux', + pathSeparator: '/', + }, + port: 45123, + apiOrigin: 'http://127.0.0.1:45123', + collabUrl: 'ws://127.0.0.1:45123/collab', + closeSession, + }); + + expect(ctx.remote?.path).toBe('/srv/wiki'); + expect(env.createWindowOpts[0]?.additionalArguments).toContain( + '--ok-remote-machine-id=build-box', + ); + expect(env.createWindowOpts[0]?.additionalArguments).toContain('--ok-remote-path=/srv/wiki'); + expect(wm.windowCount()).toBe(1); + + env.windows[0]?.fireClose(); + expect(closeSession).toHaveBeenCalledTimes(1); + expect(wm.windowCount()).toBe(0); + }); + + test('installs the remote editor trust boundary before loading the renderer', async () => { + const order: string[] = []; + const window = makeWindow(); + window.webContents.setWindowOpenHandler = mock(() => { + order.push('window-open-boundary'); + }); + window.webContents.on = mock(() => { + order.push('navigate-boundary'); + }); + window.loadFile = mock(async () => { + order.push('load-file'); + }); + env.deps.createWindow = () => window; + const wm = new WindowManager(env.deps); + + await wm.createRemoteProjectWindow({ + projectKey: 'ssh:build-box:%2Fsrv%2Fwiki', + projectName: 'wiki', + remote: { + kind: 'ssh', + machineId: 'build-box', + machineName: 'Build box', + path: '/srv/wiki', + platform: 'linux', + pathSeparator: '/', + }, + port: 45123, + apiOrigin: 'http://127.0.0.1:45123', + collabUrl: 'ws://127.0.0.1:45123/collab', + closeSession: () => {}, + }); + + expect(order).toEqual(['window-open-boundary', 'navigate-boundary', 'load-file']); + }); + + test('reserves a remote key during renderer load and closes a concurrent duplicate session', async () => { + let resolveLoad: (() => void) | undefined; + const loading = new Promise((resolve) => { + resolveLoad = resolve; + }); + const window = makeWindow(); + window.loadFile = mock(() => loading); + env.deps.createWindow = (opts) => { + env.createWindowOpts.push(opts); + env.windows.push(window); + return window; + }; + const wm = new WindowManager(env.deps); + const firstClose = mock(() => {}); + const secondClose = mock(() => {}); + const options = { + projectKey: 'ssh:build-box:%2Fsrv%2Fwiki', + projectName: 'wiki', + remote: { + kind: 'ssh' as const, + machineId: 'build-box', + machineName: 'Build box', + path: '/srv/wiki', + platform: 'linux', + pathSeparator: '/' as const, + }, + port: 45123, + apiOrigin: 'http://127.0.0.1:45123', + collabUrl: 'ws://127.0.0.1:45123/collab', + }; + + const first = wm.createRemoteProjectWindow({ ...options, closeSession: firstClose }); + const second = await wm.createRemoteProjectWindow({ ...options, closeSession: secondClose }); + expect(env.windows).toHaveLength(1); + expect(secondClose).toHaveBeenCalledTimes(1); + expect(window.focus).toHaveBeenCalled(); + + resolveLoad?.(); + expect(await first).toBe(second); + window.fireClose(); + expect(firstClose).toHaveBeenCalledTimes(1); + }); + + test('cleans up a remote reservation when its window closes during load', async () => { + let resolveLoad: (() => void) | undefined; + const loading = new Promise((resolve) => { + resolveLoad = resolve; + }); + const window = makeWindow(); + window.loadFile = mock(() => loading); + env.deps.createWindow = () => window; + const wm = new WindowManager(env.deps); + const closeSession = mock(() => {}); + + const pending = wm.createRemoteProjectWindow({ + projectKey: 'ssh:build-box:%2Fsrv%2Fwiki', + projectName: 'wiki', + remote: { + kind: 'ssh', + machineId: 'build-box', + machineName: 'Build box', + path: '/srv/wiki', + platform: 'linux', + pathSeparator: '/', + }, + port: 45123, + apiOrigin: 'http://127.0.0.1:45123', + collabUrl: 'ws://127.0.0.1:45123/collab', + closeSession, + }); + window.markDestroyed(); + window.fireClose(); + resolveLoad?.(); + + await expect(pending).rejects.toThrow('closed while loading'); + expect(closeSession).toHaveBeenCalledTimes(1); + expect(wm.windowCount()).toBe(0); + }); + + test('transactionally replaces a remote window after releasing the original session', async () => { + const wm = new WindowManager(env.deps); + const projectKey = 'ssh:build-box:%2Fsrv%2Fwiki'; + const remote = { + kind: 'ssh' as const, + machineId: 'build-box', + machineName: 'Build box', + path: '/srv/wiki', + platform: 'linux', + pathSeparator: '/' as const, + }; + const originalTunnelRetire = mock(() => {}); + const originalSessionClose = mock(() => originalTunnelRetire()); + const original = await wm.createRemoteProjectWindow({ + projectKey, + projectName: 'wiki', + remote, + port: 45123, + apiOrigin: 'http://127.0.0.1:45123', + collabUrl: 'ws://127.0.0.1:45123/collab', + retireTunnel: originalTunnelRetire, + closeSession: originalSessionClose, + }); + const originalWindow = env.windows[0]; + if (!originalWindow) throw new Error('original remote window missing'); + releaseRemoteOrigin(original); + + const replacementSessionClose = mock(() => {}); + const replacementTunnelRetire = mock(() => {}); + const replacement = await wm.replaceRemoteProjectWindow( + { + projectKey, + projectName: 'wiki', + remote, + port: 45124, + apiOrigin: 'http://127.0.0.1:45124', + collabUrl: 'ws://127.0.0.1:45124/collab', + retireTunnel: replacementTunnelRetire, + closeSession: replacementSessionClose, + }, + originalWindow, + ); + + expect(replacement).not.toBe(original); + expect(replacement.apiOrigin).toBe('http://127.0.0.1:45124'); + expect(wm.getWindowFor(projectKey)).toBe(replacement); + expect(originalWindow.close).toHaveBeenCalledTimes(1); + expect(originalWindow.isDestroyed?.()).toBe(true); + // The old companion and tunnel are gone before replacement starts. + expect(originalTunnelRetire).toHaveBeenCalledTimes(1); + expect(replacementTunnelRetire).not.toHaveBeenCalled(); + expect(originalSessionClose).toHaveBeenCalledTimes(1); + expect(replacementSessionClose).not.toHaveBeenCalled(); + + env.windows[1]?.close?.(); + expect(replacementSessionClose).toHaveBeenCalledTimes(1); + expect(originalSessionClose).toHaveBeenCalledTimes(1); + expect(wm.windowCount()).toBe(0); + }); + + test('keeps the original editor visible when the replacement window fails to load', async () => { + const wm = new WindowManager(env.deps); + const projectKey = 'ssh:build-box:%2Fsrv%2Fwiki'; + const remote = { + kind: 'ssh' as const, + machineId: 'build-box', + machineName: 'Build box', + path: '/srv/wiki', + platform: 'linux', + pathSeparator: '/' as const, + }; + const originalTunnelRetire = mock(() => {}); + const originalSessionClose = mock(() => originalTunnelRetire()); + const original = await wm.createRemoteProjectWindow({ + projectKey, + projectName: 'wiki', + remote, + port: 45123, + apiOrigin: 'http://127.0.0.1:45123', + collabUrl: 'ws://127.0.0.1:45123/collab', + retireTunnel: originalTunnelRetire, + closeSession: originalSessionClose, + }); + const originalWindow = env.windows[0]; + if (!originalWindow) throw new Error('original remote window missing'); + releaseRemoteOrigin(original); + + const failedWindow = makeWindow(); + failedWindow.loadFile = mock(() => Promise.reject(new Error('replacement load failed'))); + env.deps.createWindow = (opts) => { + env.createWindowOpts.push(opts); + env.windows.push(failedWindow); + return failedWindow; + }; + const replacementSessionClose = mock(() => {}); + await expect( + wm.replaceRemoteProjectWindow( + { + projectKey, + projectName: 'wiki', + remote, + port: 45124, + apiOrigin: 'http://127.0.0.1:45124', + collabUrl: 'ws://127.0.0.1:45124/collab', + closeSession: replacementSessionClose, + }, + originalWindow, + ), + ).rejects.toThrow('replacement load failed'); + + expect(failedWindow.close).toHaveBeenCalledTimes(1); + expect(replacementSessionClose).toHaveBeenCalledTimes(1); + expect(originalWindow.close).not.toHaveBeenCalled(); + expect(originalTunnelRetire).toHaveBeenCalledTimes(1); + expect(originalSessionClose).toHaveBeenCalledTimes(1); + expect(wm.getWindowFor(projectKey)).toBe(original); + }); + + test('releases each owned session before repeated remote replacements', async () => { + const wm = new WindowManager(env.deps); + const projectKey = 'ssh:build-box:%2Fsrv%2Fwiki'; + const remote = { + kind: 'ssh' as const, + machineId: 'build-box', + machineName: 'Build box', + path: '/srv/wiki', + platform: 'linux', + pathSeparator: '/' as const, + }; + const retires = [mock(() => {}), mock(() => {}), mock(() => {})]; + const closes = retires.map((retire) => mock(() => retire())); + + let current = await wm.createRemoteProjectWindow({ + projectKey, + projectName: 'wiki', + remote, + port: 45123, + apiOrigin: 'http://127.0.0.1:45123', + collabUrl: 'ws://127.0.0.1:45123/collab', + retireTunnel: retires[0], + closeSession: closes[0] as () => void, + }); + + releaseRemoteOrigin(current); + current = await wm.replaceRemoteProjectWindow( + { + projectKey, + projectName: 'wiki', + remote, + port: 45124, + apiOrigin: 'http://127.0.0.1:45124', + collabUrl: 'ws://127.0.0.1:45124/collab', + retireTunnel: retires[1], + closeSession: closes[1] as () => void, + }, + current.window, + ); + expect(retires[0]).toHaveBeenCalledTimes(1); + expect(retires[1]).not.toHaveBeenCalled(); + expect(closes[0]).toHaveBeenCalledTimes(1); + expect(closes[1]).not.toHaveBeenCalled(); + expect(closes[2]).not.toHaveBeenCalled(); + + releaseRemoteOrigin(current); + const latest = await wm.replaceRemoteProjectWindow( + { + projectKey, + projectName: 'wiki', + remote, + port: 45125, + apiOrigin: 'http://127.0.0.1:45125', + collabUrl: 'ws://127.0.0.1:45125/collab', + retireTunnel: retires[2], + closeSession: closes[2] as () => void, + }, + current.window, + ); + expect(retires[0]).toHaveBeenCalledTimes(1); + expect(retires[1]).toHaveBeenCalledTimes(1); + expect(retires[2]).not.toHaveBeenCalled(); + expect(closes[0]).toHaveBeenCalledTimes(1); + expect(closes[1]).toHaveBeenCalledTimes(1); + expect(closes[2]).not.toHaveBeenCalled(); + + latest.window.close?.(); + expect(closes[2]).toHaveBeenCalledTimes(1); + }); + + test('refuses a replacement after its originating remote window has closed', async () => { + const wm = new WindowManager(env.deps); + const projectKey = 'ssh:build-box:%2Fsrv%2Fwiki'; + const remote = { + kind: 'ssh' as const, + machineId: 'build-box', + machineName: 'Build box', + path: '/srv/wiki', + platform: 'linux', + pathSeparator: '/' as const, + }; + const originalSessionClose = mock(() => {}); + const original = await wm.createRemoteProjectWindow({ + projectKey, + projectName: 'wiki', + remote, + port: 45123, + apiOrigin: 'http://127.0.0.1:45123', + collabUrl: 'ws://127.0.0.1:45123/collab', + closeSession: originalSessionClose, + }); + original.window.close?.(); + const replacementSessionClose = mock(() => {}); + + await expect( + wm.replaceRemoteProjectWindow( + { + projectKey, + projectName: 'wiki', + remote, + port: 45124, + apiOrigin: 'http://127.0.0.1:45124', + collabUrl: 'ws://127.0.0.1:45124/collab', + closeSession: replacementSessionClose, + }, + original.window, + ), + ).rejects.toThrow('closed before it could be replaced'); + + expect(replacementSessionClose).toHaveBeenCalledTimes(1); + expect(originalSessionClose).toHaveBeenCalledTimes(1); + expect(env.windows).toHaveLength(1); + expect(wm.getWindowFor(projectKey)).toBeUndefined(); + }); + + test('does not resurrect a remote project closed while its replacement loads', async () => { + const wm = new WindowManager(env.deps); + const projectKey = 'ssh:build-box:%2Fsrv%2Fwiki'; + const remote = { + kind: 'ssh' as const, + machineId: 'build-box', + machineName: 'Build box', + path: '/srv/wiki', + platform: 'linux', + pathSeparator: '/' as const, + }; + const originalTunnelRetire = mock(() => {}); + const originalSessionClose = mock(() => originalTunnelRetire()); + const original = await wm.createRemoteProjectWindow({ + projectKey, + projectName: 'wiki', + remote, + port: 45123, + apiOrigin: 'http://127.0.0.1:45123', + collabUrl: 'ws://127.0.0.1:45123/collab', + retireTunnel: originalTunnelRetire, + closeSession: originalSessionClose, + }); + const originalWindow = env.windows[0]; + if (!originalWindow) throw new Error('original remote window missing'); + releaseRemoteOrigin(original); + let resolveLoad: (() => void) | undefined; + const loading = new Promise((resolve) => { + resolveLoad = resolve; + }); + const replacementWindow = makeWindow(); + replacementWindow.loadFile = mock(() => loading); + env.deps.createWindow = (opts) => { + env.createWindowOpts.push(opts); + env.windows.push(replacementWindow); + return replacementWindow; + }; + const replacementSessionClose = mock(() => {}); + + const replacement = wm.replaceRemoteProjectWindow( + { + projectKey, + projectName: 'wiki', + remote, + port: 45124, + apiOrigin: 'http://127.0.0.1:45124', + collabUrl: 'ws://127.0.0.1:45124/collab', + closeSession: replacementSessionClose, + }, + originalWindow, + ); + originalWindow.close?.(); + resolveLoad?.(); + + await expect(replacement).rejects.toThrow('closed while it was being replaced'); + expect(replacementWindow.close).toHaveBeenCalledTimes(1); + expect(replacementSessionClose).toHaveBeenCalledTimes(1); + expect(originalTunnelRetire).toHaveBeenCalledTimes(1); + expect(originalSessionClose).toHaveBeenCalledTimes(1); + expect(wm.getWindowFor(projectKey)).toBeUndefined(); + }); + + test('does not commit a replacement closed while the originating window is closing', async () => { + const wm = new WindowManager(env.deps); + const projectKey = 'ssh:build-box:%2Fsrv%2Fwiki'; + const remote = { + kind: 'ssh' as const, + machineId: 'build-box', + machineName: 'Build box', + path: '/srv/wiki', + platform: 'linux', + pathSeparator: '/' as const, + }; + const originalSessionClose = mock(() => {}); + await wm.createRemoteProjectWindow({ + projectKey, + projectName: 'wiki', + remote, + port: 45123, + apiOrigin: 'http://127.0.0.1:45123', + collabUrl: 'ws://127.0.0.1:45123/collab', + closeSession: originalSessionClose, + }); + const original = wm.getWindowFor(projectKey); + if (!original) throw new Error('original remote context missing'); + const originalWindow = env.windows[0]; + if (!originalWindow) throw new Error('original remote window missing'); + releaseRemoteOrigin(original); + // Hold closeAndAwait open so the replacement can close inside that await. + originalWindow.close = mock(() => {}); + + const replacementWindow = makeWindow(); + env.deps.createWindow = (opts) => { + env.createWindowOpts.push(opts); + env.windows.push(replacementWindow); + return replacementWindow; + }; + const replacementSessionClose = mock(() => {}); + const replacement = wm.replaceRemoteProjectWindow( + { + projectKey, + projectName: 'wiki', + remote, + port: 45124, + apiOrigin: 'http://127.0.0.1:45124', + collabUrl: 'ws://127.0.0.1:45124/collab', + closeSession: replacementSessionClose, + }, + originalWindow, + ); + await wait(0); + expect(originalWindow.close).toHaveBeenCalledTimes(1); + + replacementWindow.close?.(); + originalWindow.markDestroyed(); + originalWindow.fireClose(); + + await expect(replacement).rejects.toThrow('closed before the replacement committed'); + expect(replacementSessionClose).toHaveBeenCalledTimes(1); + expect(originalSessionClose).toHaveBeenCalledTimes(1); + expect(wm.getWindowFor(projectKey)).toBeUndefined(); + }); + test('createProjectWindow forks utility, sends init, waits for ready, creates window', async () => { const wm = new WindowManager(env.deps); const promise = wm.createProjectWindow({ projectPath: '/tmp/test-project' }); diff --git a/packages/desktop/tests/utility/pty-host.test.ts b/packages/desktop/tests/utility/pty-host.test.ts index d9498093b..f8af06042 100644 --- a/packages/desktop/tests/utility/pty-host.test.ts +++ b/packages/desktop/tests/utility/pty-host.test.ts @@ -156,6 +156,21 @@ describe('setupPtyHost — create', () => { ]); }); + test('spawns a main-owned executable with exact argv and no shell interpretation', () => { + const h = makeHarness({ env: { SHELL: '/bin/zsh', PATH: '/usr/bin' } }); + h.fire( + CREATE({ + executable: '/usr/bin/ssh', + args: ['-tt', '--', 'devbox', "exec '$SHELL' -l"], + launchCommand: 'must-not-run-locally', + }), + ); + + expect(h.spawnCalls[0]?.file).toBe('/usr/bin/ssh'); + expect(h.spawnCalls[0]?.args).toEqual(['-tt', '--', 'devbox', "exec '$SHELL' -l"]); + expect(h.spawnCalls[0]?.options.cwd).toBe('/project/root'); + }); + test('falls back to /bin/zsh when SHELL is unset', () => { const h = makeHarness({ env: { PATH: '/usr/bin' } }); h.fire(CREATE()); diff --git a/packages/server/src/boot.ts b/packages/server/src/boot.ts index 95082dc4a..429066d9b 100644 --- a/packages/server/src/boot.ts +++ b/packages/server/src/boot.ts @@ -194,6 +194,7 @@ export interface BootServerOptions | 'debounce' | 'maxDebounce' | 'gitEnabled' + | 'watcherBackend' | 'commitDebounceMs' | 'wipRef' | 'destroyTimeoutMs' @@ -617,6 +618,7 @@ async function bootServerInner(opts: BootServerOptions): Promise { debounce: opts.debounce, maxDebounce: opts.maxDebounce, gitEnabled: opts.gitEnabled, + watcherBackend: opts.watcherBackend, commitDebounceMs: opts.commitDebounceMs, wipRef: opts.wipRef, enableTestRoutes: opts.enableTestRoutes, diff --git a/packages/server/src/file-watcher.test.ts b/packages/server/src/file-watcher.test.ts index c56e67805..4772ab86f 100644 --- a/packages/server/src/file-watcher.test.ts +++ b/packages/server/src/file-watcher.test.ts @@ -1,4 +1,4 @@ -import { afterEach, beforeEach, describe, expect, test } from 'bun:test'; +import { afterEach, beforeEach, describe, expect, mock, test } from 'bun:test'; import { mkdirSync, realpathSync, symlinkSync, writeFileSync } from 'node:fs'; import { mkdtemp, rm } from 'node:fs/promises'; import { tmpdir } from 'node:os'; @@ -16,6 +16,7 @@ import { reconcileFileIndexAfterFilterRebuild, registerWrite, startWatcher, + startWatcherBackend, updateLastKnownHash, writeTracker, } from './file-watcher'; @@ -364,6 +365,32 @@ describe('classifyEvents', () => { // ─── startWatcher file index ──────────────────────────────────────────────── +describe('watcher backend selection', () => { + const subscription = { unsubscribe: async () => {} }; + + test('forced Chokidar bypasses Parcel entirely', async () => { + const startParcel = mock(async () => subscription); + const startChokidar = mock(async () => subscription); + + const selected = await startWatcherBackend('chokidar', startParcel, startChokidar); + + expect(selected.backend).toBe('chokidar'); + expect(startParcel).not.toHaveBeenCalled(); + expect(startChokidar).toHaveBeenCalledTimes(1); + }); + + test('forced Parcel fails instead of falling back to Chokidar', async () => { + const startParcel = mock(async () => null); + const startChokidar = mock(async () => subscription); + + await expect(startWatcherBackend('parcel', startParcel, startChokidar)).rejects.toThrow( + '@parcel/watcher unavailable for file watching (forced backend)', + ); + expect(startParcel).toHaveBeenCalledTimes(1); + expect(startChokidar).not.toHaveBeenCalled(); + }); +}); + describe('startWatcher file index', () => { let tmpDir: string; let contentDir: string; diff --git a/packages/server/src/file-watcher.ts b/packages/server/src/file-watcher.ts index 6f7d91a85..4b70b3787 100644 --- a/packages/server/src/file-watcher.ts +++ b/packages/server/src/file-watcher.ts @@ -1547,12 +1547,11 @@ async function startParcelWatcher( parcel = await import('@parcel/watcher'); } catch (err) { // Expected in packaged builds: @parcel/watcher is a native module that - // isn't bundled, so we fall back to chokidar. The `watching … backend: - // chokidar` info line records the outcome — this is debug-only so the - // terminal stays clean (it's a routine fallback, not an error). + // isn't bundled. The later `watching … backend: chokidar` info line records + // the selected backend when automatic fallback is allowed. getLogger('file-watcher').debug( { err: err instanceof Error ? err.message : String(err) }, - '[file-watcher] @parcel/watcher import failed; falling back to chokidar', + '[file-watcher] @parcel/watcher import failed', ); return null; } @@ -1592,7 +1591,7 @@ async function startParcelWatcher( return subscription; } catch (err) { - console.warn('[file-watcher] @parcel/watcher subscribe failed, falling back to chokidar:', err); + console.warn('[file-watcher] @parcel/watcher subscribe failed:', err); return null; } } @@ -1679,6 +1678,26 @@ async function startChokidarWatcher( // ─── Watcher ───────────────────────────────────────────────────────────────── +/** Select exactly one watcher backend, preserving forced-backend failures. */ +export async function startWatcherBackend( + forceBackend: WatcherBackend | undefined, + startParcel: () => Promise, + startChokidar: () => Promise, +): Promise<{ subscription: AsyncSubscription; backend: WatcherBackend }> { + if (forceBackend === 'chokidar') { + return { subscription: await startChokidar(), backend: 'chokidar' }; + } + + const parcelSubscription = await startParcel(); + if (parcelSubscription) { + return { subscription: parcelSubscription, backend: 'parcel' }; + } + if (forceBackend === 'parcel') { + throw new Error('@parcel/watcher unavailable for file watching (forced backend)'); + } + return { subscription: await startChokidar(), backend: 'chokidar' }; +} + /** * Start watching a content directory for external .md file changes. * Calls onDiskEvent for each classified event (not our own persistence writes). @@ -1696,6 +1715,7 @@ export async function startWatcher( contentDirRaw: string, onDiskEvent: (event: DiskEvent) => Promise, contentFilter?: ContentFilter, + opts: { forceBackend?: WatcherBackend } = {}, ): Promise { let contentDir: string; try { @@ -1741,30 +1761,29 @@ export async function startWatcher( let subscription: AsyncSubscription; let backend: WatcherBackend; try { - const parcelSub = await startParcelWatcher( - contentDir, - contentFilter, - fileIndex, - folderIndex, - onDiskEvent, - aliasMap, - bumpFileIndexGeneration, - ); - if (parcelSub) { - subscription = parcelSub; - backend = 'parcel'; - } else { - subscription = await startChokidarWatcher( - contentDir, - contentFilter, - fileIndex, - folderIndex, - onDiskEvent, - aliasMap, - bumpFileIndexGeneration, - ); - backend = 'chokidar'; - } + ({ subscription, backend } = await startWatcherBackend( + opts.forceBackend, + () => + startParcelWatcher( + contentDir, + contentFilter, + fileIndex, + folderIndex, + onDiskEvent, + aliasMap, + bumpFileIndexGeneration, + ), + () => + startChokidarWatcher( + contentDir, + contentFilter, + fileIndex, + folderIndex, + onDiskEvent, + aliasMap, + bumpFileIndexGeneration, + ), + )); } catch (e) { clearInterval(evictionInterval); throw e; diff --git a/packages/server/src/server-factory.ts b/packages/server/src/server-factory.ts index af4d7deae..8770b62f3 100644 --- a/packages/server/src/server-factory.ts +++ b/packages/server/src/server-factory.ts @@ -180,6 +180,8 @@ export interface ServerOptions { debounce?: number; maxDebounce?: number; gitEnabled?: boolean; + /** Pin the filesystem watcher implementation instead of auto-selecting one. */ + watcherBackend?: 'parcel' | 'chokidar'; commitDebounceMs?: number; wipRef?: string; /** @@ -3301,7 +3303,9 @@ export function createServer(options: ServerOptions): ServerInstance { // cost is visible apart from the surrounding index work. const seedWalkStartMono = performance.now(); watcher = await withSpan('ok.boot.seed-walk', undefined, async () => - startWatcher(contentDir, onDiskEvent, contentFilter), + startWatcher(contentDir, onDiskEvent, contentFilter, { + forceBackend: options.watcherBackend, + }), ); recordBootPhase('seedWalkMs', Math.round(performance.now() - seedWalkStartMono)); @@ -3871,6 +3875,7 @@ export function createServer(options: ServerOptions): ServerInstance { } } }, + { forceBackend: options.watcherBackend }, ); } catch (err) { // HEAD watching now falls back to chokidar when @parcel/watcher can't From 60e20a2211f0294a40f2f2474661023be2a5b56c Mon Sep 17 00:00:00 2001 From: Pablo Duce <66517318+Paduce@users.noreply.github.com> Date: Tue, 14 Jul 2026 13:39:19 +0200 Subject: [PATCH 2/2] fix(remote): bound companion shutdown --- packages/cli/src/commands/remote.test.ts | 44 ++++++++++++++++++++++++ packages/cli/src/commands/remote.ts | 32 +++++++++++++---- 2 files changed, 69 insertions(+), 7 deletions(-) diff --git a/packages/cli/src/commands/remote.test.ts b/packages/cli/src/commands/remote.test.ts index 2ecf4643c..fc783a8be 100644 --- a/packages/cli/src/commands/remote.test.ts +++ b/packages/cli/src/commands/remote.test.ts @@ -519,6 +519,50 @@ describe('runRemoteServe', () => { expect(pauseCalls).toBe(1); }); + test('forces a remote companion to exit when server teardown hangs', async () => { + const stdinListeners = new Map<'end' | 'close', () => void | Promise>(); + let deadline: (() => void) | undefined; + let forcedExitCode: number | undefined; + + await runRemoteServe({ + config, + cwd: '/project', + resolvedContentDir: '/project', + nonce: NONCE, + deps: { + canonicalize: (path) => path, + readLock: () => null, + boot: async () => ({ + port: 45678, + ready: Promise.resolve(), + destroy: () => new Promise(() => {}), + }), + writeStdout: () => {}, + onceSignal: () => {}, + offSignal: () => {}, + watchStdinForDisconnect: true, + onceStdin: (event, listener) => stdinListeners.set(event, listener), + offStdin: (event) => stdinListeners.delete(event), + resumeStdin: () => {}, + pauseStdin: () => {}, + scheduleShutdownDeadline: (listener) => { + deadline = listener; + return () => { + deadline = undefined; + }; + }, + forceExit: (code) => { + forcedExitCode = code; + }, + }, + }); + + void stdinListeners.get('end')?.(); + expect(deadline).toBeDefined(); + deadline?.(); + expect(forcedExitCode).toBe(1); + }); + test('destroys a partial boot when async readiness rejects', async () => { let destroyCalls = 0; const failure = new Error('watcher init failed'); diff --git a/packages/cli/src/commands/remote.ts b/packages/cli/src/commands/remote.ts index 4ba04bb41..edb25b826 100644 --- a/packages/cli/src/commands/remote.ts +++ b/packages/cli/src/commands/remote.ts @@ -91,6 +91,8 @@ export interface RemoteServeDeps { offStdin(event: StdinLifecycleEvent, listener: StdinLifecycleListener): void; resumeStdin(): void; pauseStdin(): void; + scheduleShutdownDeadline(listener: () => void): () => void; + forceExit(code: number): void; setExitCode(code: number): void; platform: NodeJS.Platform; pathSeparator: string; @@ -98,6 +100,8 @@ export interface RemoteServeDeps { runtimeVersion: string; } +const REMOTE_SHUTDOWN_DEADLINE_MS = 5_000; + export interface RunRemoteServeOptions { config: Config; cwd: string; @@ -282,6 +286,12 @@ function defaultDeps(): RemoteServeDeps { pauseStdin: () => { process.stdin.pause(); }, + scheduleShutdownDeadline: (listener) => { + const timer = setTimeout(listener, REMOTE_SHUTDOWN_DEADLINE_MS); + timer.unref(); + return () => clearTimeout(timer); + }, + forceExit: (code) => process.exit(code), setExitCode: (code) => { process.exitCode = code; }, @@ -410,13 +420,21 @@ export async function runRemoteServe(options: RunRemoteServeOptions): Promise => { - shutdownPromise ??= booted - .destroy() - .catch(() => { - deps.setExitCode(1); - deps.writeStdout(formatRemoteErrorLine(options.nonce, 'startup-failed')); - }) - .finally(removeLifecycleHandlers); + if (shutdownPromise === null) { + // A stuck server teardown must not leave an orphaned SSH companion whose + // live PID pins the project lock indefinitely. + const cancelDeadline = deps.scheduleShutdownDeadline(() => deps.forceExit(1)); + shutdownPromise = booted + .destroy() + .catch(() => { + deps.setExitCode(1); + deps.writeStdout(formatRemoteErrorLine(options.nonce, 'startup-failed')); + }) + .finally(() => { + cancelDeadline(); + removeLifecycleHandlers(); + }); + } return shutdownPromise; };