From 2a1aa1d28552d212d4063b5a866d98fe5b3feed9 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Fri, 17 Jul 2026 00:54:44 +0000 Subject: [PATCH 01/24] Update README downloads badge --- docs/assets/readme-downloads.svg | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/assets/readme-downloads.svg b/docs/assets/readme-downloads.svg index 417924fc997..d96e1bddd40 100644 --- a/docs/assets/readme-downloads.svg +++ b/docs/assets/readme-downloads.svg @@ -1,5 +1,5 @@ - - downloads: 5.8m + + downloads: 5.9m @@ -15,7 +15,7 @@ downloads downloads - 5.8m - 5.8m + 5.9m + 5.9m From f8b9c2984d7de1c450855a6c305fdee9c2ce29df Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 16 Jul 2026 18:02:51 -0700 Subject: [PATCH 02/24] fix(watcher): host macOS git-common narrow watch in the watcher child (#9091) * fix(watcher): host macOS git-common narrow watch in the watcher child The darwin-only .git/worktrees narrow stream was the last production path loading @parcel/watcher's native module inside the Electron main process. watcher.node 2.5.6 has unsynchronized cross-thread access to watcher state during unsubscribe/root-deletion (FSEventsBackend.cc:60/ 64 vs :336, :250 vs :262, and the deletedRoot double-stopStream) that corrupts the hosting process heap; `git worktree prune` deleting an empty worktrees dir makes that overlap routine, and the resulting PartitionAlloc trap killed the whole app (issue #8732, EXC_BREAKPOINT on the FSEvents thread in Watcher::isIgnored). Route the subscription through the existing crash-isolated watcher child supervisor instead, matching every other native watch (#7547). Error and root-deletion teardown/re-arm semantics are unchanged; a watcher-child interruption now reports a structural change so the worktree list re-syncs across the resubscribe gap. Fixes #8732 * fix(watcher): ignore callbacks from replaced git-common watches --- .../ipc/worktree-git-common-watch.test.ts | 189 ++++++++++++++++++ src/main/ipc/worktree-git-common-watch.ts | 53 +++-- 2 files changed, 225 insertions(+), 17 deletions(-) create mode 100644 src/main/ipc/worktree-git-common-watch.test.ts diff --git a/src/main/ipc/worktree-git-common-watch.test.ts b/src/main/ipc/worktree-git-common-watch.test.ts new file mode 100644 index 00000000000..9cc70f1d7e0 --- /dev/null +++ b/src/main/ipc/worktree-git-common-watch.test.ts @@ -0,0 +1,189 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { mkdtemp, mkdir, realpath, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { subscribeViaWatcherProcess } from './parcel-watcher-process' +import type { + WatcherProcessCallback, + WatcherProcessHooks +} from './parcel-watcher-process-subscription' +import type { WorktreeBaseWatchTarget } from './worktree-base-directory-event-filter' +import type { WorktreeBasePollEvent } from './worktree-base-directory-poller' +import { startGitCommonWatch } from './worktree-git-common-watch' + +vi.mock('./parcel-watcher-process', () => ({ + subscribeViaWatcherProcess: vi.fn() +})) + +const POLL_MS = 25 + +type ChildSubscription = { + dir: string + callback: WatcherProcessCallback + hooks: WatcherProcessHooks + unsubscribe: ReturnType +} + +describe('worktree git-common narrow watch (darwin)', () => { + const cleanups: (() => Promise)[] = [] + const subscribeMock = vi.mocked(subscribeViaWatcherProcess) + let childSubscriptions: ChildSubscription[] = [] + + afterEach(async () => { + await Promise.all(cleanups.splice(0).map((cleanup) => cleanup())) + childSubscriptions = [] + subscribeMock.mockReset() + }) + + function installSubscribeMock(): void { + subscribeMock.mockImplementation(async (dir, callback, _opts, hooks = {}) => { + const unsubscribe = vi.fn(async () => {}) + childSubscriptions.push({ dir, callback, hooks, unsubscribe }) + return { unsubscribe } + }) + } + + async function makeCommonDir(withWorktrees: boolean): Promise { + const root = await mkdtemp(join(tmpdir(), 'orca-git-common-watch-')) + cleanups.push(() => rm(root, { recursive: true, force: true })) + const commonDir = await realpath(root) + if (withWorktrees) { + await mkdir(join(commonDir, 'worktrees')) + } + return commonDir + } + + function makeTarget(path: string): WorktreeBaseWatchTarget { + return { + key: `git-common:local:${path}`, + kind: 'git-common', + path, + repos: new Map([['repo-1', { repoId: 'repo-1', repoName: 'project', nestWorkspaces: false }]]) + } + } + + async function startWatch(commonDir: string, received: WorktreeBasePollEvent[][]): Promise { + const watch = await startGitCommonWatch( + makeTarget(commonDir), + (events) => received.push(events), + POLL_MS, + 'darwin' + ) + cleanups.push(() => watch.unsubscribe()) + } + + it('hosts the narrow stream in the watcher child, not in-process', async () => { + installSubscribeMock() + const commonDir = await makeCommonDir(true) + const received: WorktreeBasePollEvent[][] = [] + await startWatch(commonDir, received) + + expect(subscribeMock).toHaveBeenCalledTimes(1) + const [dir, , opts] = subscribeMock.mock.calls[0] + expect(dir).toBe(join(commonDir, 'worktrees')) + expect(opts).toEqual({}) + + const entryPath = join(commonDir, 'worktrees', 'wt-a') + childSubscriptions[0].callback(null, [{ type: 'create', path: entryPath }]) + expect(received.flat()).toContainEqual({ type: 'create', path: entryPath }) + }) + + it('tears down and re-arms when the watched root is deleted', async () => { + installSubscribeMock() + const commonDir = await makeCommonDir(true) + const worktreesDir = join(commonDir, 'worktrees') + const received: WorktreeBasePollEvent[][] = [] + await startWatch(commonDir, received) + + await rm(worktreesDir, { recursive: true, force: true }) + childSubscriptions[0].callback(null, [{ type: 'delete', path: worktreesDir }]) + await vi.waitFor(() => { + expect(childSubscriptions[0].unsubscribe).toHaveBeenCalledTimes(1) + }) + expect(received.flat()).toContainEqual({ type: 'delete', path: worktreesDir }) + + // The existence poll re-subscribes once a new first worktree recreates it. + await mkdir(worktreesDir) + await vi.waitFor(() => { + expect(subscribeMock).toHaveBeenCalledTimes(2) + }) + expect(received.flat()).toContainEqual({ type: 'create', path: worktreesDir }) + }) + + it('tears down and re-arms on watcher errors', async () => { + installSubscribeMock() + const commonDir = await makeCommonDir(true) + const worktreesDir = join(commonDir, 'worktrees') + const received: WorktreeBasePollEvent[][] = [] + await startWatch(commonDir, received) + + childSubscriptions[0].callback(new Error('watcher child reported failure'), []) + await vi.waitFor(() => { + expect(childSubscriptions[0].unsubscribe).toHaveBeenCalledTimes(1) + }) + // The error is surfaced as a structural change so worktrees re-sync. + expect(received.flat()).toContainEqual({ type: 'update', path: worktreesDir }) + + // The dir still exists, so the existence poll re-subscribes. + await vi.waitFor(() => { + expect(subscribeMock).toHaveBeenCalledTimes(2) + }) + + const receivedAfterRearm = received.length + childSubscriptions[0].callback(new Error('late error from replaced watcher'), []) + childSubscriptions[0].callback(null, [ + { type: 'create', path: join(worktreesDir, 'late-old-event') } + ]) + childSubscriptions[0].hooks.onInterruption?.() + + // A replaced watch cannot tear down its successor or report stale events. + expect(received).toHaveLength(receivedAfterRearm) + expect(childSubscriptions[1].unsubscribe).not.toHaveBeenCalled() + }) + + it('reports a structural change after a watcher-child interruption', async () => { + installSubscribeMock() + const commonDir = await makeCommonDir(true) + const worktreesDir = join(commonDir, 'worktrees') + const received: WorktreeBasePollEvent[][] = [] + await startWatch(commonDir, received) + + childSubscriptions[0].hooks.onInterruption?.() + expect(received.flat()).toContainEqual({ type: 'update', path: worktreesDir }) + // The supervisor resubscribed the same record; no teardown should happen. + expect(childSubscriptions[0].unsubscribe).not.toHaveBeenCalled() + }) + + it('arms via existence polling when the worktrees dir appears later', async () => { + installSubscribeMock() + const commonDir = await makeCommonDir(false) + const received: WorktreeBasePollEvent[][] = [] + await startWatch(commonDir, received) + expect(subscribeMock).not.toHaveBeenCalled() + + await mkdir(join(commonDir, 'worktrees')) + await vi.waitFor(() => { + expect(subscribeMock).toHaveBeenCalledTimes(1) + }) + }) + + it('stops forwarding events and unsubscribes the child on dispose', async () => { + installSubscribeMock() + const commonDir = await makeCommonDir(true) + const received: WorktreeBasePollEvent[][] = [] + const watch = await startGitCommonWatch( + makeTarget(commonDir), + (events) => received.push(events), + POLL_MS, + 'darwin' + ) + await watch.unsubscribe() + expect(childSubscriptions[0].unsubscribe).toHaveBeenCalledTimes(1) + + received.length = 0 + childSubscriptions[0].callback(null, [ + { type: 'create', path: join(commonDir, 'worktrees', 'late') } + ]) + expect(received).toHaveLength(0) + }) +}) diff --git a/src/main/ipc/worktree-git-common-watch.ts b/src/main/ipc/worktree-git-common-watch.ts index 39899b1e149..705963ed726 100644 --- a/src/main/ipc/worktree-git-common-watch.ts +++ b/src/main/ipc/worktree-git-common-watch.ts @@ -1,5 +1,6 @@ import { stat } from 'node:fs/promises' import { join } from 'node:path' +import { subscribeViaWatcherProcess } from './parcel-watcher-process' import type { WorktreeBaseWatchTarget } from './worktree-base-directory-event-filter' import type { WorktreeBasePollEvent, @@ -20,6 +21,10 @@ import { // included). Other platforms: dir-listing poll (no fseventsd to protect, and // on Windows an open directory handle on `worktrees/` could interfere with // `git worktree prune` removing it). +// The native stream is hosted in the crash-isolated watcher child, never the +// Electron main process: watcher.node teardown races heap-corrupt the hosting +// process when unsubscribe overlaps in-flight callbacks (issue #8732), and +// root deletion via `git worktree prune` makes that overlap routine here. // Why: branch switches and commits made in the primary checkout rewrite these // top-level files (linked-worktree equivalents live under `worktrees/`). @@ -157,12 +162,14 @@ async function startGitCommonNarrowWatch( return false } let errored = false + let active = true // Why: parcel tears its native stream down when the watched root is // deleted (e.g. `git worktree prune` removing an empty worktrees dir) — // sometimes surfaced as an error, sometimes as a delete event for the // root. Either way: notify, drop the dead stream, and let the existence // poll re-arm when a future worktree add recreates the dir. const teardownAndRearm = (): void => { + active = false errored = true const current = subscription subscription = null @@ -172,26 +179,38 @@ async function startGitCommonNarrowWatch( armExistencePoll() } try { - const watcher = await import('@parcel/watcher') - const sub = await watcher.subscribe(worktreesDir, (error, events) => { - if (disposed) { - return - } - if (error) { - onEvents([{ type: 'update', path: worktreesDir }]) - teardownAndRearm() - return - } - if (events.length > 0) { - const rootGone = events.some( - (event) => event.type === 'delete' && event.path === worktreesDir - ) - onEvents(events.map((event) => ({ type: event.type, path: event.path }))) - if (rootGone) { + const sub = await subscribeViaWatcherProcess( + worktreesDir, + (error, events) => { + if (disposed || !active) { + return + } + if (error) { + onEvents([{ type: 'update', path: worktreesDir }]) teardownAndRearm() + return + } + if (events.length > 0) { + const rootGone = events.some( + (event) => event.type === 'delete' && event.path === worktreesDir + ) + onEvents(events.map((event) => ({ type: event.type, path: event.path }))) + if (rootGone) { + teardownAndRearm() + } + } + }, + {}, + { + // Why: a watcher-child crash drops events during the automatic + // resubscribe gap; report a structural change so worktrees re-sync. + onInterruption: () => { + if (!disposed && active) { + onEvents([{ type: 'update', path: worktreesDir }]) + } } } - }) + ) if (disposed || errored) { void sub.unsubscribe().catch(() => {}) return !errored From f10c40591cb4f88c5a57163c9339dc39e182b324 Mon Sep 17 00:00:00 2001 From: OrcaWin Date: Thu, 16 Jul 2026 18:05:38 -0700 Subject: [PATCH 03/24] fix(win): set UTF-8 console code page for Git Bash terminals (#9054) Launch Windows Git Bash as `bash -c 'chcp.com 65001 >/dev/null 2>&1; exec "$BASH" --login -i'` so the ConPTY console is UTF-8 before a byte-writing UTF-8 TUI runs, matching what cmd.exe and PowerShell already do. Fixes CP437 mojibake for byte-writing agents in Git Bash tabs. Scoped to Git Bash; does not address PowerShell-reported mojibake. --- src/main/daemon/pty-subprocess.test.ts | 2 +- src/main/ipc/pty.test.ts | 2 +- src/main/providers/local-pty-provider.test.ts | 2 +- src/main/providers/windows-shell-args.test.ts | 17 +++++++++++++++-- src/main/providers/windows-shell-args.ts | 10 +++++++++- 5 files changed, 27 insertions(+), 6 deletions(-) diff --git a/src/main/daemon/pty-subprocess.test.ts b/src/main/daemon/pty-subprocess.test.ts index b4e5fc8e26c..ae8c834d894 100644 --- a/src/main/daemon/pty-subprocess.test.ts +++ b/src/main/daemon/pty-subprocess.test.ts @@ -2210,7 +2210,7 @@ describe('createPtySubprocess', () => { expect(spawnMock).toHaveBeenCalledWith( 'C:\\PortableGit\\bin\\bash.exe', - ['--login', '-i'], + ['-c', 'chcp.com 65001 >/dev/null 2>&1; exec "$BASH" --login -i'], expect.objectContaining({ cwd: 'C:\\Users\\jin\\repo', env: expect.objectContaining({ CHERE_INVOKING: '1' }) diff --git a/src/main/ipc/pty.test.ts b/src/main/ipc/pty.test.ts index 4f08581282b..64047cb1c46 100644 --- a/src/main/ipc/pty.test.ts +++ b/src/main/ipc/pty.test.ts @@ -6497,7 +6497,7 @@ describe('registerPtyHandlers', () => { expect(spawnMock).toHaveBeenCalledWith( 'C:\\Program Files\\Git\\bin\\bash.exe', - ['--login', '-i'], + ['-c', 'chcp.com 65001 >/dev/null 2>&1; exec "$BASH" --login -i'], expect.objectContaining({ env: expect.objectContaining({ CHERE_INVOKING: '1' }) }) diff --git a/src/main/providers/local-pty-provider.test.ts b/src/main/providers/local-pty-provider.test.ts index f69505161f2..54f8c2a110c 100644 --- a/src/main/providers/local-pty-provider.test.ts +++ b/src/main/providers/local-pty-provider.test.ts @@ -936,7 +936,7 @@ describe('LocalPtyProvider', () => { expect(spawnMock).toHaveBeenCalledWith( 'C:\\Program Files\\Git\\bin\\bash.exe', - ['--login', '-i'], + ['-c', 'chcp.com 65001 >/dev/null 2>&1; exec "$BASH" --login -i'], expect.objectContaining({ cwd: 'C:\\Users\\jin\\repo', env: expect.objectContaining({ diff --git a/src/main/providers/windows-shell-args.test.ts b/src/main/providers/windows-shell-args.test.ts index 41e9b3b9488..273763f7017 100644 --- a/src/main/providers/windows-shell-args.test.ts +++ b/src/main/providers/windows-shell-args.test.ts @@ -196,14 +196,27 @@ describe('resolveWindowsShellLaunchArgs', () => { ]) }) - it('starts Git Bash as an interactive login shell without changing cwd', () => { + it('starts Git Bash as an interactive login shell with UTF-8 console setup', () => { const result = resolveWindowsShellLaunchArgs( 'C:\\Program Files\\Git\\bin\\bash.exe', 'C:\\Users\\alice\\code', 'C:\\Users\\alice' ) - expect(result.shellArgs).toEqual(['--login', '-i']) + // Why: Git Bash inherits the ConPTY OEM code page (CP437), so a byte-writing + // UTF-8 TUI mojibakes unless the console is switched to UTF-8 before it runs. + // Assert the contract behaviorally: chcp.com 65001 must precede the exec'd + // interactive login shell (which preserves `--login -i` and the shell's + // foreground identity), rather than pinning the exact command string. + expect(result.shellArgs[0]).toBe('-c') + const bashCommand = result.shellArgs[1] + expect(bashCommand).toContain('chcp.com 65001') + expect(bashCommand).toMatch(/exec "\$BASH" --login -i$/) + // The UTF-8 switch must run before the login shell is exec'd. + expect(bashCommand.indexOf('chcp.com')).toBeLessThan(bashCommand.indexOf('exec')) + // Must stay fail-open: `;` (not `&&`) so a missing chcp.com can't abort the + // exec and kill the terminal on startup. + expect(bashCommand).not.toContain('&&') expect(result.effectiveCwd).toBe('C:\\Users\\alice\\code') expect(result.validationCwd).toBe('C:\\Users\\alice\\code') }) diff --git a/src/main/providers/windows-shell-args.ts b/src/main/providers/windows-shell-args.ts index 4977cc11158..c860fa1a11d 100644 --- a/src/main/providers/windows-shell-args.ts +++ b/src/main/providers/windows-shell-args.ts @@ -16,6 +16,14 @@ const CMD_EXE_COMMAND_LINE_MAX_CHARS = 8191 const STARTUP_COMMAND_TEXT_MAX_CHARS = 6000 const POWERSHELL_ENCODED_COMMAND_ARG_MAX_CHARS = 28_000 const CMD_UTF8_SETUP_COMMAND = 'chcp 65001 > nul' +// Why: Git for Windows' bash inherits the ConPTY console's OEM code page +// (CP437), so a TUI that writes UTF-8 bytes straight to the console — agents +// like Claude Code use WriteFile, not WriteConsoleW — renders as mojibake +// (`❯` -> `Γ¥»`). Switch the console to UTF-8, then exec the normal interactive +// login shell; cmd.exe and PowerShell already do the equivalent. The `;` (not +// `&&`) keeps startup working even if chcp.com is missing. +const GIT_BASH_UTF8_LOGIN_COMMAND = + 'chcp.com 65001 >/dev/null 2>&1; exec "$BASH" --login -i' /** Result of resolving a Windows shell to its launch args + effective cwd. * @@ -176,7 +184,7 @@ export function resolveWindowsShellLaunchArgs( if (isWindowsGitBashShellPath(shellPath)) { return { - shellArgs: ['--login', '-i'], + shellArgs: ['-c', GIT_BASH_UTF8_LOGIN_COMMAND], effectiveCwd: nativeCwd, validationCwd: nativeCwd } From cdc7a5ca4695d2ae04572ffe7885ae99e63f4c6e Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Thu, 16 Jul 2026 18:08:47 -0700 Subject: [PATCH 04/24] Relay ssh image diff (#9112) Co-authored-by: Orca --- src/relay/git-working-file-read.test.ts | 27 +++++++++++++++++++++++++ src/relay/git-working-file-read.ts | 2 +- 2 files changed, 28 insertions(+), 1 deletion(-) diff --git a/src/relay/git-working-file-read.test.ts b/src/relay/git-working-file-read.test.ts index b4f8e3e6f7f..4f11437568b 100644 --- a/src/relay/git-working-file-read.test.ts +++ b/src/relay/git-working-file-read.test.ts @@ -35,4 +35,31 @@ describe('readWorkingDiffFile', () => { isBinary: true }) }) + + it('returns base64 content for previewable image working-tree files', async () => { + tmpDir = await mkdtemp(path.join(tmpdir(), 'relay-working-file-')) + const filePath = path.join(tmpDir, 'image.png') + // Why: PNG signature + trailing null byte forces the binary-detection heuristic to trip. + const pngBuffer = Buffer.concat([ + Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]), + Buffer.from([0x00]) + ]) + await writeFile(filePath, pngBuffer) + + await expect(readWorkingDiffFile(filePath)).resolves.toEqual({ + content: pngBuffer.toString('base64'), + isBinary: true + }) + }) + + it('returns empty content for non-previewable binary working-tree files', async () => { + tmpDir = await mkdtemp(path.join(tmpdir(), 'relay-working-file-')) + const filePath = path.join(tmpDir, 'archive.bin') + await writeFile(filePath, Buffer.from([0x00, 0x01, 0x02, 0x00, 0x03])) + + await expect(readWorkingDiffFile(filePath)).resolves.toEqual({ + content: '', + isBinary: true + }) + }) }) diff --git a/src/relay/git-working-file-read.ts b/src/relay/git-working-file-read.ts index 2c7db8e9778..30c762c3a24 100644 --- a/src/relay/git-working-file-read.ts +++ b/src/relay/git-working-file-read.ts @@ -16,7 +16,7 @@ export async function readWorkingDiffFile( return { content: '', isBinary: true } } const buffer = await readFile(absPath) - return bufferToBlob(buffer) + return bufferToBlob(buffer, absPath) } catch { return { content: '', isBinary: false } } From a03a3dd51b61ba94d8351c468529f8017795b0c1 Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Thu, 16 Jul 2026 18:21:08 -0700 Subject: [PATCH 05/24] Render png on mobile (#9087) * Add mobile image-diff previews via shared data-URI builder - Extracts a `buildImageDataUri` helper (src/shared/image-data-uri.ts) shared by the desktop ImageViewer and mobile, so both trim whitespace-wrapped base64 and skip non-previewable mimes (e.g. application/pdf) the same way. - Adds mobile-diff-image-preview.ts to render binary git.diff results (add/modify/ delete) as images instead of falling back to "Binary preview unavailable". - Extracts resolveMobileFileTabDoc to consolidate the session file-tab loading logic (diff/image/html/text) out of the route file for testability. * Fix stale binary image fallback for empty modified diffs and relay reads - mobileDiffImageDataUri now distinguishes a true deletion (modified side absent) from a modify whose binary bytes arrived empty (relay/size-cap cases), returning null instead of the stale pre-change image - readWorkingDiffFile passes the file path to bufferToBlob so relay working-tree reads can detect previewable image extensions instead of always reporting empty binary content - add mobile-file-tab-doc.test.ts covering diff/image/binary/text resolution paths * Regenerate skill bundle manifest for 1.4.144-rc.2 Co-authored-by: Orca * fix(review): trim comments to AGENTS.md's one/two-line why-only rule Comments in mobile-diff-image-preview.ts and mobile-file-tab-doc.ts ran 3-6 lines and narrated mechanism instead of stating only the non-obvious reason, per AGENTS.md's "Code Comments: Document the Why, Briefly" rule. Co-authored-by: Orca * Distinguish read failures from true deletions in binary diff results - Working-tree stat/readFile errors and relay reads previously collapsed onto the same empty-content signal as a genuine deletion, letting previewers fall back to stale original bytes on a failed read. - Add modifiedDeleted/missing flags through status.ts, git-handler-ops, and git-working-file-read so only proven deletions trigger the original-bytes fallback; failed reads now return null. - Tighten buildImageDataUri to accept only image/* mimes instead of special-casing application/pdf. * fix(relay): expect missing:false on index blob maxBuffer overflow readBlobAtIndex now returns a missing flag so staged deletions are distinct from size-capped binary reads; update the overflow test. * Allow opening deleted files to show pre-delete text or image diffs Deleted files can now be opened to view their pre-delete content via git.diff (including images via modifiedDeleted). Only unresolved conflicts remain unopenable. Centralizes the canOpen rule in canOpenMobileGitStatusEntry() to keep opener guards consistent across the mobile source control UI. --------- Co-authored-by: Orca --- .../app/h/[hostId]/session/[worktreeId].tsx | 94 +----------- .../files/mobile-diff-image-preview.test.ts | 123 ++++++++++++++++ mobile/src/files/mobile-diff-image-preview.ts | 32 +++++ mobile/src/files/mobile-file-tab-doc.test.ts | 136 ++++++++++++++++++ mobile/src/files/mobile-file-tab-doc.ts | 94 ++++++++++++ .../source-control/mobile-git-status.test.ts | 26 ++++ .../src/source-control/mobile-git-status.ts | 6 + .../mobile-source-control-screen-state.ts | 6 +- .../use-mobile-source-control-openers.ts | 10 +- src/main/git/status.test.ts | 31 ++++ src/main/git/status.ts | 42 ++++-- src/main/providers/windows-shell-args.ts | 3 +- src/relay/git-handler-blob-readers.test.ts | 3 +- src/relay/git-handler-ops.ts | 21 ++- src/relay/git-working-file-read.test.ts | 36 ++++- src/relay/git-working-file-read.ts | 34 +++-- .../src/components/editor/ImageViewer.tsx | 5 +- .../native-chat/native-chat-runtime-send.ts | 1 - src/shared/image-data-uri.test.ts | 30 ++++ src/shared/image-data-uri.ts | 20 +++ src/shared/types.ts | 7 + 21 files changed, 631 insertions(+), 129 deletions(-) create mode 100644 mobile/src/files/mobile-diff-image-preview.test.ts create mode 100644 mobile/src/files/mobile-diff-image-preview.ts create mode 100644 mobile/src/files/mobile-file-tab-doc.test.ts create mode 100644 mobile/src/files/mobile-file-tab-doc.ts create mode 100644 src/shared/image-data-uri.test.ts create mode 100644 src/shared/image-data-uri.ts diff --git a/mobile/app/h/[hostId]/session/[worktreeId].tsx b/mobile/app/h/[hostId]/session/[worktreeId].tsx index 0251da70490..d6bf1187811 100644 --- a/mobile/app/h/[hostId]/session/[worktreeId].tsx +++ b/mobile/app/h/[hostId]/session/[worktreeId].tsx @@ -141,7 +141,6 @@ import { saveCustomKeys, type CustomKey } from '../../../../src/components/CustomKeyModal' -import { buildMobileDiffLines } from '../../../../src/session/mobile-diff-lines' import { addMobileDiffComment, formatDiffComments, @@ -177,7 +176,7 @@ import { useMobileTerminalPaste } from '../../../../src/session/use-mobile-termi import { useTerminalLiveInputModePreference } from '../../../../src/session/use-terminal-live-input-mode-preference' import { MobileTerminalLiveInputStatus } from '../../../../src/session/MobileTerminalLiveInputStatus' import { MobileTerminalInputActions } from '../../../../src/session/MobileTerminalInputActions' -import { classifyMobileArtifact } from '../../../../src/session/mobile-artifact-kind' +import { resolveMobileFileTabDoc } from '../../../../src/files/mobile-file-tab-doc' import { openMobileTerminalFileTap } from '../../../../src/session/mobile-terminal-file-tap-open' import { useLiveWorktreeName } from '../../../../src/session/use-live-worktree-name' import { @@ -2043,93 +2042,12 @@ export default function SessionScreen() { } setFileDocs((prev) => new Map(prev).set(tab.id, { status: 'loading' })) try { - if (tab.diffSource === 'staged' || tab.diffSource === 'unstaged') { - const response = await client.sendRequest('git.diff', { - worktree: `id:${worktreeId}`, - filePath: tab.relativePath, - staged: tab.diffSource === 'staged' - }) - if (!response.ok) { - throw new Error((response as RpcFailure).error.message) - } - const result = (response as RpcSuccess).result as - | { - kind: 'text' - originalContent: string - modifiedContent: string - } - | { kind: 'binary' } - if (result.kind !== 'text') { - throw new Error('binary_file') - } - const diff = buildMobileDiffLines(result.originalContent, result.modifiedContent) - setFileDocs((prev) => - new Map(prev).set(tab.id, { - status: 'ready', - kind: 'diff', - lines: diff.lines, - truncated: diff.truncated - }) - ) - return - } - const artifactKind = classifyMobileArtifact(tab.relativePath) - if (artifactKind === 'image') { - const preview = await client.sendRequest('files.readPreview', { - worktree: `id:${worktreeId}`, - relativePath: tab.relativePath - }) - if (!preview.ok) { - throw new Error((preview as RpcFailure).error.message) - } - const result = (preview as RpcSuccess).result as { - content: string - isImage?: boolean - mimeType?: string - } - if (!result.isImage || !result.mimeType || result.content.length === 0) { - throw new Error('binary_file') - } - setFileDocs((prev) => - new Map(prev).set(tab.id, { - status: 'ready', - kind: 'image', - dataUri: `data:${result.mimeType};base64,${result.content}` - }) - ) - return - } - const response = await client.sendRequest('files.read', { - worktree: `id:${worktreeId}`, - relativePath: tab.relativePath + const doc = await resolveMobileFileTabDoc(client, { + worktreeId, + relativePath: tab.relativePath, + diffSource: tab.diffSource }) - if (!response.ok) { - throw new Error((response as RpcFailure).error.message) - } - const result = (response as RpcSuccess).result as { - content: string - truncated: boolean - byteLength: number - } - if (artifactKind === 'html') { - setFileDocs((prev) => - new Map(prev).set(tab.id, { - status: 'ready', - kind: 'html', - content: result.content - }) - ) - return - } - setFileDocs((prev) => - new Map(prev).set(tab.id, { - status: 'ready', - kind: 'file', - content: result.content, - truncated: result.truncated, - byteLength: result.byteLength - }) - ) + setFileDocs((prev) => new Map(prev).set(tab.id, doc)) } catch (err) { const message = err instanceof Error ? err.message : '' const previewMessage = diff --git a/mobile/src/files/mobile-diff-image-preview.test.ts b/mobile/src/files/mobile-diff-image-preview.test.ts new file mode 100644 index 00000000000..e63f532b28d --- /dev/null +++ b/mobile/src/files/mobile-diff-image-preview.test.ts @@ -0,0 +1,123 @@ +import { describe, expect, it } from 'vitest' +import { mobileDiffImageDataUri } from './mobile-diff-image-preview' + +describe('mobileDiffImageDataUri', () => { + it('renders a modified image diff from the post-change bytes', () => { + expect( + mobileDiffImageDataUri({ + kind: 'binary', + originalContent: 'b2xk', + modifiedContent: 'bmV3', + isImage: true, + mimeType: 'image/png' + }) + ).toBe('data:image/png;base64,bmV3') + }) + + it('renders an added image diff (no original) from the modified bytes', () => { + expect( + mobileDiffImageDataUri({ + kind: 'binary', + originalContent: '', + modifiedContent: 'bmV3', + isImage: true, + mimeType: 'image/png' + }) + ).toBe('data:image/png;base64,bmV3') + }) + + it('falls back to the original bytes for a proven deletion (modifiedDeleted)', () => { + expect( + mobileDiffImageDataUri({ + kind: 'binary', + originalContent: 'b2xk', + originalIsBinary: true, + modifiedContent: '', + modifiedIsBinary: false, + modifiedDeleted: true, + isImage: true, + mimeType: 'image/jpeg' + }) + ).toBe('data:image/jpeg;base64,b2xk') + }) + + // The reviewer's read-failure case: a relay/SSH read returns an empty modified + // side with modifiedIsBinary false and no modifiedDeleted flag. Without a proven + // deletion, falling back to the original would show a stale pre-change image, so + // "unavailable" (null) is the honest result. + it('returns null on a read failure (empty modified, no modifiedDeleted flag)', () => { + expect( + mobileDiffImageDataUri({ + kind: 'binary', + originalContent: 'b2xk', + originalIsBinary: true, + modifiedContent: '', + modifiedIsBinary: false, + isImage: true, + mimeType: 'image/png' + }) + ).toBeNull() + }) + + // Guards the >size-cap case: the modified side IS a binary image but its bytes + // arrive empty. Falling back to the original would render the stale pre-change + // image; "unavailable" is the honest result. + it('returns null for a modify whose binary modified side is empty (no stale fallback)', () => { + expect( + mobileDiffImageDataUri({ + kind: 'binary', + originalContent: 'b2xk', + originalIsBinary: true, + modifiedContent: '', + modifiedIsBinary: true, + isImage: true, + mimeType: 'image/png' + }) + ).toBeNull() + }) + + it('returns null for a non-previewable binary (no isImage/mimeType)', () => { + expect( + mobileDiffImageDataUri({ + kind: 'binary', + originalContent: '', + modifiedContent: 'AAAA' + }) + ).toBeNull() + }) + + it('returns null when flagged as image but carrying no bytes', () => { + expect( + mobileDiffImageDataUri({ + kind: 'binary', + originalContent: '', + modifiedContent: '', + isImage: true, + mimeType: 'image/png' + }) + ).toBeNull() + }) + + it('returns null for application/pdf even when flagged as previewable', () => { + expect( + mobileDiffImageDataUri({ + kind: 'binary', + originalContent: '', + modifiedContent: 'JVBER', + isImage: true, + mimeType: 'application/pdf' + }) + ).toBeNull() + }) + + it('returns null when flagged as image but mimeType is missing', () => { + expect( + mobileDiffImageDataUri({ + kind: 'binary', + originalContent: '', + modifiedContent: 'bmV3', + isImage: true + }) + ).toBeNull() + }) +}) diff --git a/mobile/src/files/mobile-diff-image-preview.ts b/mobile/src/files/mobile-diff-image-preview.ts new file mode 100644 index 00000000000..09d7cd2ef36 --- /dev/null +++ b/mobile/src/files/mobile-diff-image-preview.ts @@ -0,0 +1,32 @@ +import { buildImageDataUri } from '../../../src/shared/image-data-uri' + +// modifiedDeleted marks a proven deletion (modified side genuinely absent); an +// empty modifiedContent alone can't, since a relay/SSH read failure also arrives +// empty with modifiedIsBinary false. +export type MobileBinaryDiffResult = { + kind: 'binary' + originalContent?: string + modifiedContent?: string + originalIsBinary?: boolean + modifiedIsBinary?: boolean + modifiedDeleted?: boolean + isImage?: boolean + mimeType?: string +} + +// Falls back to the original bytes only for a proven deletion; a read failure or +// size-capped modify — also empty on the modified side — returns null instead of +// the stale pre-change image. +export function mobileDiffImageDataUri(result: MobileBinaryDiffResult): string | null { + if (result.isImage !== true) { + return null + } + const modified = result.modifiedContent || '' + if (modified) { + return buildImageDataUri(result.mimeType, modified) + } + if (result.modifiedDeleted === true) { + return buildImageDataUri(result.mimeType, result.originalContent || '') + } + return null +} diff --git a/mobile/src/files/mobile-file-tab-doc.test.ts b/mobile/src/files/mobile-file-tab-doc.test.ts new file mode 100644 index 00000000000..a9570e6cf44 --- /dev/null +++ b/mobile/src/files/mobile-file-tab-doc.test.ts @@ -0,0 +1,136 @@ +import { describe, expect, it } from 'vitest' +import type { RpcResponse } from '../transport/types' +import { resolveMobileFileTabDoc } from './mobile-file-tab-doc' + +function ok(result: unknown): RpcResponse { + return { id: 'x', ok: true, result, _meta: { runtimeId: 'r' } } +} + +function fail(code: string, message: string): RpcResponse { + return { id: 'x', ok: false, error: { code, message }, _meta: { runtimeId: 'r' } } +} + +// Fake client that returns a canned response per RPC method. +function clientOf(byMethod: Record): { + sendRequest: (method: string) => Promise + calls: string[] +} { + const calls: string[] = [] + return { + calls, + sendRequest: (method: string) => { + calls.push(method) + const response = byMethod[method] + if (!response) { + throw new Error(`unexpected method ${method}`) + } + return Promise.resolve(response) + } + } +} + +const WT = { worktreeId: 'wt1' } + +describe('resolveMobileFileTabDoc', () => { + it('renders a staged text diff', async () => { + const client = clientOf({ + 'git.diff': ok({ kind: 'text', originalContent: 'a\n', modifiedContent: 'a\nb\n' }) + }) + const doc = await resolveMobileFileTabDoc(client, { + ...WT, + relativePath: 'a.ts', + diffSource: 'staged' + }) + expect(doc.kind).toBe('diff') + expect(client.calls).toEqual(['git.diff']) + }) + + it('renders an unstaged image diff from the modified bytes', async () => { + const client = clientOf({ + 'git.diff': ok({ + kind: 'binary', + originalContent: 'b2xk', + modifiedContent: 'bmV3', + modifiedIsBinary: true, + isImage: true, + mimeType: 'image/png' + }) + }) + const doc = await resolveMobileFileTabDoc(client, { + ...WT, + relativePath: 'm1.png', + diffSource: 'unstaged' + }) + expect(doc).toEqual({ status: 'ready', kind: 'image', dataUri: 'data:image/png;base64,bmV3' }) + }) + + it('throws binary_file for an image modify whose bytes are empty (no stale fallback)', async () => { + const client = clientOf({ + 'git.diff': ok({ + kind: 'binary', + originalContent: 'b2xk', + modifiedContent: '', + modifiedIsBinary: true, + isImage: true, + mimeType: 'image/png' + }) + }) + await expect( + resolveMobileFileTabDoc(client, { ...WT, relativePath: 'm1.png', diffSource: 'unstaged' }) + ).rejects.toThrow('binary_file') + }) + + it('throws binary_file for a non-image binary diff', async () => { + const client = clientOf({ 'git.diff': ok({ kind: 'binary', modifiedContent: 'AAAA' }) }) + await expect( + resolveMobileFileTabDoc(client, { ...WT, relativePath: 'a.bin', diffSource: 'unstaged' }) + ).rejects.toThrow('binary_file') + }) + + it('renders a live image preview via files.readPreview', async () => { + const client = clientOf({ + 'files.readPreview': ok({ content: 'bmV3', isImage: true, mimeType: 'image/png' }) + }) + const doc = await resolveMobileFileTabDoc(client, { ...WT, relativePath: 'logo.png' }) + expect(doc).toEqual({ status: 'ready', kind: 'image', dataUri: 'data:image/png;base64,bmV3' }) + expect(client.calls).toEqual(['files.readPreview']) + }) + + it('throws binary_file when readPreview returns no image bytes', async () => { + const client = clientOf({ + 'files.readPreview': ok({ content: '', isImage: true, mimeType: 'image/png' }) + }) + await expect( + resolveMobileFileTabDoc(client, { ...WT, relativePath: 'logo.png' }) + ).rejects.toThrow('binary_file') + }) + + it('renders html source via files.read', async () => { + const client = clientOf({ + 'files.read': ok({ content: '

hi

', truncated: false, byteLength: 11 }) + }) + const doc = await resolveMobileFileTabDoc(client, { ...WT, relativePath: 'page.html' }) + expect(doc).toEqual({ status: 'ready', kind: 'html', content: '

hi

' }) + }) + + it('renders a plain text file via files.read', async () => { + const client = clientOf({ + 'files.read': ok({ content: 'hello', truncated: true, byteLength: 5 }) + }) + const doc = await resolveMobileFileTabDoc(client, { ...WT, relativePath: 'notes.txt' }) + expect(doc).toEqual({ + status: 'ready', + kind: 'file', + content: 'hello', + truncated: true, + byteLength: 5 + }) + }) + + it('propagates the RPC error message when a read fails', async () => { + const client = clientOf({ 'files.read': fail('EIO', 'file_too_large') }) + await expect( + resolveMobileFileTabDoc(client, { ...WT, relativePath: 'notes.txt' }) + ).rejects.toThrow('file_too_large') + }) +}) diff --git a/mobile/src/files/mobile-file-tab-doc.ts b/mobile/src/files/mobile-file-tab-doc.ts new file mode 100644 index 00000000000..78977b5fd30 --- /dev/null +++ b/mobile/src/files/mobile-file-tab-doc.ts @@ -0,0 +1,94 @@ +import { buildImageDataUri } from '../../../src/shared/image-data-uri' +import { classifyMobileArtifact } from '../session/mobile-artifact-kind' +import { buildMobileDiffLines, type MobileDiffLine } from '../session/mobile-diff-lines' +import type { RpcClient } from '../transport/rpc-client' +import type { RpcFailure, RpcSuccess } from '../transport/types' +import { mobileDiffImageDataUri, type MobileBinaryDiffResult } from './mobile-diff-image-preview' + +type FileTabDocClient = Pick + +// The ready doc a session file tab renders. Mirrors the ready arm of the route's +// FileDocState; kept in src so the loader stays testable without the route. +export type MobileFileTabDoc = + | { status: 'ready'; kind: 'file'; content: string; truncated: boolean; byteLength: number } + | { status: 'ready'; kind: 'diff'; lines: MobileDiffLine[]; truncated: boolean } + | { status: 'ready'; kind: 'image'; dataUri: string } + | { status: 'ready'; kind: 'html'; content: string } + +export type MobileFileTabDocRequest = { + worktreeId: string + relativePath: string + diffSource?: 'staged' | 'unstaged' | 'branch' | 'commit' +} + +// Throws 'binary_file'/'file_too_large'/the RPC error message; callers map those +// to error docs. +export async function resolveMobileFileTabDoc( + client: FileTabDocClient, + request: MobileFileTabDocRequest +): Promise { + const worktree = `id:${request.worktreeId}` + const { relativePath } = request + if (request.diffSource === 'staged' || request.diffSource === 'unstaged') { + const response = await client.sendRequest('git.diff', { + worktree, + filePath: relativePath, + staged: request.diffSource === 'staged' + }) + if (!response.ok) { + throw new Error((response as RpcFailure).error.message) + } + const result = (response as RpcSuccess).result as + | { kind: 'text'; originalContent: string; modifiedContent: string } + | MobileBinaryDiffResult + if (result.kind !== 'text') { + // Render image diffs (add/modify/delete) from the base64 the host already + // sends; only non-previewable binaries stay unavailable. + const dataUri = mobileDiffImageDataUri(result) + if (!dataUri) { + throw new Error('binary_file') + } + return { status: 'ready', kind: 'image', dataUri } + } + const diff = buildMobileDiffLines(result.originalContent, result.modifiedContent) + return { status: 'ready', kind: 'diff', lines: diff.lines, truncated: diff.truncated } + } + + const artifactKind = classifyMobileArtifact(relativePath) + if (artifactKind === 'image') { + const preview = await client.sendRequest('files.readPreview', { worktree, relativePath }) + if (!preview.ok) { + throw new Error((preview as RpcFailure).error.message) + } + const result = (preview as RpcSuccess).result as { + content: string + isImage?: boolean + mimeType?: string + } + const dataUri = result.isImage ? buildImageDataUri(result.mimeType, result.content) : null + if (!dataUri) { + throw new Error('binary_file') + } + return { status: 'ready', kind: 'image', dataUri } + } + + const response = await client.sendRequest('files.read', { worktree, relativePath }) + if (!response.ok) { + throw new Error((response as RpcFailure).error.message) + } + const result = (response as RpcSuccess).result as { + content: string + truncated: boolean + byteLength: number + } + if (artifactKind === 'html') { + return { status: 'ready', kind: 'html', content: result.content } + } + return { + status: 'ready', + kind: 'file', + content: result.content, + truncated: result.truncated, + byteLength: result.byteLength + } +} diff --git a/mobile/src/source-control/mobile-git-status.test.ts b/mobile/src/source-control/mobile-git-status.test.ts index 9c3575a75b2..a9e2b554f91 100644 --- a/mobile/src/source-control/mobile-git-status.test.ts +++ b/mobile/src/source-control/mobile-git-status.test.ts @@ -2,6 +2,7 @@ import { describe, expect, expectTypeOf, it } from 'vitest' import type { GitStatusResult } from '../../../src/shared/git-status-types' import { buildMobileSourceControlSections, + canOpenMobileGitStatusEntry, countStagedEntries, countUnstagedEntries, getStageablePaths, @@ -65,6 +66,31 @@ describe('mobile source control status helpers', () => { expect(isMobileGitDiscardableEntry(conflictedEntries[2])).toBe(false) }) + it('allows opening deleted files so pre-delete text/image diffs can load', () => { + expect( + canOpenMobileGitStatusEntry({ path: 'deleted.png', status: 'deleted', area: 'unstaged' }) + ).toBe(true) + expect( + canOpenMobileGitStatusEntry({ path: 'logo.png', status: 'modified', area: 'unstaged' }) + ).toBe(true) + expect( + canOpenMobileGitStatusEntry({ + path: 'conflict.ts', + status: 'modified', + area: 'unstaged', + conflictStatus: 'unresolved' + }) + ).toBe(false) + expect( + canOpenMobileGitStatusEntry({ + path: 'resolved.ts', + status: 'modified', + area: 'unstaged', + conflictStatus: 'resolved_locally' + }) + ).toBe(true) + }) + it('sorts entries by desktop-compatible conflict rank, then path', () => { const sections = buildMobileSourceControlSections([ { path: 'zeta.ts', status: 'modified', area: 'unstaged' }, diff --git a/mobile/src/source-control/mobile-git-status.ts b/mobile/src/source-control/mobile-git-status.ts index c6bfde9b8c2..65fcf61e211 100644 --- a/mobile/src/source-control/mobile-git-status.ts +++ b/mobile/src/source-control/mobile-git-status.ts @@ -90,6 +90,12 @@ export function isMobileGitDiscardableEntry(entry: MobileGitStatusEntry): boolea return entry.conflictStatus !== 'unresolved' && entry.conflictStatus !== 'resolved_locally' } +// Why: unresolved conflicts are not a stable file to open. Deletions are — +// git.diff still returns the pre-delete side (text or image via modifiedDeleted). +export function canOpenMobileGitStatusEntry(entry: MobileGitStatusEntry): boolean { + return entry.conflictStatus !== 'unresolved' +} + export function isMobileGitUnavailable(code: string | undefined, message: string | undefined) { return ( code === 'forbidden' || diff --git a/mobile/src/source-control/mobile-source-control-screen-state.ts b/mobile/src/source-control/mobile-source-control-screen-state.ts index eb20cdaa3ac..78a588d19ff 100644 --- a/mobile/src/source-control/mobile-source-control-screen-state.ts +++ b/mobile/src/source-control/mobile-source-control-screen-state.ts @@ -20,6 +20,7 @@ import type { MobileGitBranchCompareSummary } from './mobile-branch-compare' import { + canOpenMobileGitStatusEntry, isMobileGitDiscardableEntry, isMobileGitStageableEntry, type MobileGitFileStatus, @@ -58,15 +59,14 @@ export type MobileGitStatusEntryView = MobileGitStatusEntry & { } // Decorate raw status entries with the row-level capability/action-id fields the -// file list needs. Deleted/unresolved entries are not openable (matches the -// opener guards). +// file list needs. Opener guards must use the same canOpen rule. export function buildMobileGitStatusEntryViews( entries: readonly MobileGitStatusEntry[] ): MobileGitStatusEntryView[] { return entries.map((entry) => ({ ...entry, canDiscard: isMobileGitDiscardableEntry(entry), - canOpen: entry.status !== 'deleted' && entry.conflictStatus !== 'unresolved', + canOpen: canOpenMobileGitStatusEntry(entry), canStage: isMobileGitStageableEntry(entry), discardActionId: `discard:${entry.path}`, stageActionId: `stage:${entry.path}`, diff --git a/mobile/src/source-control/use-mobile-source-control-openers.ts b/mobile/src/source-control/use-mobile-source-control-openers.ts index be3febb4a80..bf0eff63cf6 100644 --- a/mobile/src/source-control/use-mobile-source-control-openers.ts +++ b/mobile/src/source-control/use-mobile-source-control-openers.ts @@ -12,7 +12,11 @@ import { canOpenMobileBranchCompareDiff, type MobileGitBranchChangeEntry } from './mobile-branch-compare' -import { isMobileGitUnavailable, type MobileGitStatusEntry } from './mobile-git-status' +import { + canOpenMobileGitStatusEntry, + isMobileGitUnavailable, + type MobileGitStatusEntry +} from './mobile-git-status' import { buildMobileReviewFileRoute } from './mobile-review-route' import type { GitDiffTextResult, @@ -70,7 +74,9 @@ export function useMobileSourceControlOpeners(params: Params) { const openFile = useCallback( async (entry: MobileGitStatusEntry) => { - if (entry.status === 'deleted' || entry.conflictStatus === 'unresolved') { + // Deletions are openable (pre-delete text/image via git.diff); only block + // unresolved conflicts, matching canOpenMobileGitStatusEntry / row UI. + if (!canOpenMobileGitStatusEntry(entry)) { return } if (openingPathRef.current || busyActionRef.current) { diff --git a/src/main/git/status.test.ts b/src/main/git/status.test.ts index 8af536a31a6..fd5e16f91c5 100644 --- a/src/main/git/status.test.ts +++ b/src/main/git/status.test.ts @@ -476,6 +476,37 @@ describe('getDiff', () => { }) }) + it('flags a deleted image so previewers can fall back to the original bytes', async () => { + const pngBuffer = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x00]) + gitExecFileAsyncBufferMock.mockResolvedValueOnce({ stdout: pngBuffer }) + statMock.mockRejectedValueOnce(Object.assign(new Error('missing'), { code: 'ENOENT' })) + + const result = await getDiff('/repo', 'assets/deleted.png', false) + + expect(result.kind).toBe('binary') + if (result.kind !== 'binary') { + throw new Error('expected binary diff result') + } + expect(result.modifiedDeleted).toBe(true) + expect(result.originalContent).toBe(pngBuffer.toString('base64')) + expect(result.modifiedContent).toBe('') + }) + + it('does not treat an unreadable working-tree image as a deletion', async () => { + const pngBuffer = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x00]) + gitExecFileAsyncBufferMock.mockResolvedValueOnce({ stdout: pngBuffer }) + statMock.mockResolvedValueOnce({ isFile: () => true, size: 5 }) + readFileMock.mockRejectedValueOnce(new Error('EIO')) + + const result = await getDiff('/repo', 'assets/unreadable.png', false) + + expect(result.kind).toBe('binary') + if (result.kind !== 'binary') { + throw new Error('expected binary diff result') + } + expect(result.modifiedDeleted).toBeUndefined() + }) + it('coalesces concurrent identical staged diff reads while in flight', async () => { const leftBlob = deferredBuffer('head-content\n') const rightBlob = deferredBuffer('index-content\n') diff --git a/src/main/git/status.ts b/src/main/git/status.ts index a0c103b744c..ae818b52567 100644 --- a/src/main/git/status.ts +++ b/src/main/git/status.ts @@ -1329,6 +1329,7 @@ async function loadDiff( let modifiedContent = '' let originalIsBinary = false let modifiedIsBinary = false + let modifiedDeleted = false try { const leftBlob = staged @@ -1343,22 +1344,30 @@ async function loadDiff( const rightBlob = await readGitBlobAtIndexPath(worktreePath, filePath, options) modifiedContent = rightBlob.content modifiedIsBinary = rightBlob.isBinary + modifiedDeleted = !rightBlob.exists } else { const workingTreeBlob = await readWorkingTreeFile(path.join(worktreePath, filePath)) modifiedContent = workingTreeBlob.content modifiedIsBinary = workingTreeBlob.isBinary + modifiedDeleted = !workingTreeBlob.exists } } catch { // Fallback } - return buildDiffResult( + const result = buildDiffResult( originalContent, modifiedContent, originalIsBinary, modifiedIsBinary, filePath ) + // Why: mark a proven deletion so previewers can fall back to the original bytes + // without mistaking a read failure's empty modified side for a deletion. + if (result.kind === 'binary' && modifiedDeleted) { + return { ...result, modifiedDeleted: true } + } + return result } export async function getBranchCompare( @@ -1859,20 +1868,33 @@ async function readGitBlobAtOidPath( } async function readWorkingTreeFile(filePath: string): Promise { + let fileStat try { - const fileStat = await stat(filePath) - if (!fileStat.isFile()) { - return { content: '', isBinary: false, exists: false } - } - if (fileStat.size > MAX_GIT_SHOW_BYTES) { - // Why: git blob reads are capped through maxBuffer; mirror that bound for - // unstaged working-tree content before readFile can pull in huge assets. - return { content: '', isBinary: true, exists: true } + fileStat = await stat(filePath) + } catch (error) { + // Why: ENOENT means the working-tree file is genuinely gone (a deletion); + // any other stat error is a read failure, which must not be reported as an + // absence since callers fall back to the original bytes only for deletions. + return { + content: '', + isBinary: false, + exists: (error as NodeJS.ErrnoException)?.code !== 'ENOENT' } + } + if (!fileStat.isFile()) { + return { content: '', isBinary: false, exists: false } + } + if (fileStat.size > MAX_GIT_SHOW_BYTES) { + // Why: git blob reads are capped through maxBuffer; mirror that bound for + // unstaged working-tree content before readFile can pull in huge assets. + return { content: '', isBinary: true, exists: true } + } + try { const buffer = await readFile(filePath) return bufferToBlob(buffer, filePath) } catch { - return { content: '', isBinary: false, exists: false } + // Why: the file exists but could not be read — a read failure, not a deletion. + return { content: '', isBinary: false, exists: true } } } diff --git a/src/main/providers/windows-shell-args.ts b/src/main/providers/windows-shell-args.ts index c860fa1a11d..523aee72a6a 100644 --- a/src/main/providers/windows-shell-args.ts +++ b/src/main/providers/windows-shell-args.ts @@ -22,8 +22,7 @@ const CMD_UTF8_SETUP_COMMAND = 'chcp 65001 > nul' // (`❯` -> `Γ¥»`). Switch the console to UTF-8, then exec the normal interactive // login shell; cmd.exe and PowerShell already do the equivalent. The `;` (not // `&&`) keeps startup working even if chcp.com is missing. -const GIT_BASH_UTF8_LOGIN_COMMAND = - 'chcp.com 65001 >/dev/null 2>&1; exec "$BASH" --login -i' +const GIT_BASH_UTF8_LOGIN_COMMAND = 'chcp.com 65001 >/dev/null 2>&1; exec "$BASH" --login -i' /** Result of resolving a Windows shell to its launch args + effective cwd. * diff --git a/src/relay/git-handler-blob-readers.test.ts b/src/relay/git-handler-blob-readers.test.ts index 3a32eccaed1..b2b9e87ecbb 100644 --- a/src/relay/git-handler-blob-readers.test.ts +++ b/src/relay/git-handler-blob-readers.test.ts @@ -44,6 +44,7 @@ describe('git blob readers', () => { const result = await readBlobAtIndex(gitBuffer, '/repo', 'large.log') - expect(result).toEqual({ content: '', isBinary: true }) + // Why: overflow is size-capped content, not a staged deletion (missing: false). + expect(result).toEqual({ content: '', isBinary: true, missing: false }) }) }) diff --git a/src/relay/git-handler-ops.ts b/src/relay/git-handler-ops.ts index 395e31badb3..afe75a2b582 100644 --- a/src/relay/git-handler-ops.ts +++ b/src/relay/git-handler-ops.ts @@ -52,17 +52,19 @@ export async function readBlobAtIndex( gitBuffer: GitBufferExec, cwd: string, filePath: string -): Promise<{ content: string; isBinary: boolean }> { +): Promise<{ content: string; isBinary: boolean; missing: boolean }> { // Why: Git's `:` syntax expects forward slashes even on Windows. const gitPath = filePath.replace(/\\/g, '/') try { const buf = await gitBuffer(['show', '--end-of-options', `:${gitPath}`], cwd) - return bufferToBlob(buf, filePath) + return { ...bufferToBlob(buf, filePath), missing: false } } catch (error) { if (isGitBufferOverflowError(error)) { - return { content: '', isBinary: true } + return { content: '', isBinary: true, missing: false } } - return { content: '', isBinary: false } + // Why: a non-overflow failure means the path is absent from the index (a + // staged deletion), distinct from the size-capped case handled above. + return { content: '', isBinary: false, missing: true } } } @@ -91,6 +93,7 @@ export async function computeDiff( let modifiedContent = '' let originalIsBinary = false let modifiedIsBinary = false + let modifiedDeleted = false try { if (staged) { @@ -101,6 +104,7 @@ export async function computeDiff( const right = await readBlobAtIndex(git, worktreePath, filePath) modifiedContent = right.content modifiedIsBinary = right.isBinary + modifiedDeleted = right.missing } else { const left = compareAgainstHead ? await readBlobAtOid(git, worktreePath, 'HEAD', filePath) @@ -111,18 +115,25 @@ export async function computeDiff( const right = await readWorkingDiffFile(path.join(worktreePath, filePath)) modifiedContent = right.content modifiedIsBinary = right.isBinary + modifiedDeleted = right.missing } } catch { // Fallback to empty } - return buildDiffResult( + const result = buildDiffResult( originalContent, modifiedContent, originalIsBinary, modifiedIsBinary, filePath ) + // Why: mark a proven deletion so previewers can fall back to the original bytes + // without mistaking a read failure's empty modified side for a deletion. + if (result.kind === 'binary' && modifiedDeleted) { + return { ...result, modifiedDeleted: true } + } + return result } // ─── Branch compare ────────────────────────────────────────────────── diff --git a/src/relay/git-working-file-read.test.ts b/src/relay/git-working-file-read.test.ts index 4f11437568b..7f9824ead94 100644 --- a/src/relay/git-working-file-read.test.ts +++ b/src/relay/git-working-file-read.test.ts @@ -21,7 +21,8 @@ describe('readWorkingDiffFile', () => { await expect(readWorkingDiffFile(filePath)).resolves.toEqual({ content: 'hello', - isBinary: false + isBinary: false, + missing: false }) }) @@ -32,7 +33,32 @@ describe('readWorkingDiffFile', () => { await expect(readWorkingDiffFile(filePath)).resolves.toEqual({ content: '', - isBinary: true + isBinary: true, + missing: false + }) + }) + + it('base64-encodes a previewable image by its extension', async () => { + tmpDir = await mkdtemp(path.join(tmpdir(), 'relay-working-file-')) + const filePath = path.join(tmpDir, 'icon.png') + const pngBytes = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x01]) + await writeFile(filePath, pngBytes) + + await expect(readWorkingDiffFile(filePath)).resolves.toEqual({ + content: pngBytes.toString('base64'), + isBinary: true, + missing: false + }) + }) + + it('reports an absent working-tree file as a deletion', async () => { + tmpDir = await mkdtemp(path.join(tmpdir(), 'relay-working-file-')) + const filePath = path.join(tmpDir, 'deleted.png') + + await expect(readWorkingDiffFile(filePath)).resolves.toEqual({ + content: '', + isBinary: false, + missing: true }) }) @@ -48,7 +74,8 @@ describe('readWorkingDiffFile', () => { await expect(readWorkingDiffFile(filePath)).resolves.toEqual({ content: pngBuffer.toString('base64'), - isBinary: true + isBinary: true, + missing: false }) }) @@ -59,7 +86,8 @@ describe('readWorkingDiffFile', () => { await expect(readWorkingDiffFile(filePath)).resolves.toEqual({ content: '', - isBinary: true + isBinary: true, + missing: false }) }) }) diff --git a/src/relay/git-working-file-read.ts b/src/relay/git-working-file-read.ts index 30c762c3a24..05cf1ebcdce 100644 --- a/src/relay/git-working-file-read.ts +++ b/src/relay/git-working-file-read.ts @@ -5,19 +5,31 @@ const MAX_RELAY_DIFF_WORKING_FILE_BYTES = 10 * 1024 * 1024 export async function readWorkingDiffFile( absPath: string -): Promise<{ content: string; isBinary: boolean }> { +): Promise<{ content: string; isBinary: boolean; missing: boolean }> { + let fileStat + try { + fileStat = await stat(absPath) + } catch (error) { + // Why: ENOENT means the working-tree file is genuinely gone (a deletion); + // any other stat error is a read failure we must not mistake for one, since + // callers fall back to the original bytes only for a proven deletion. + const missing = (error as NodeJS.ErrnoException)?.code === 'ENOENT' + return { content: '', isBinary: false, missing } + } + if (!fileStat.isFile()) { + return { content: '', isBinary: false, missing: true } + } + if (fileStat.size > MAX_RELAY_DIFF_WORKING_FILE_BYTES) { + // Why: mirror local git diff reads, which cap blob transfer at 10MB. + return { content: '', isBinary: true, missing: false } + } try { - const fileStat = await stat(absPath) - if (!fileStat.isFile()) { - return { content: '', isBinary: false } - } - if (fileStat.size > MAX_RELAY_DIFF_WORKING_FILE_BYTES) { - // Why: mirror local git diff reads, which cap blob transfer at 10MB. - return { content: '', isBinary: true } - } const buffer = await readFile(absPath) - return bufferToBlob(buffer, absPath) + // Why: bufferToBlob needs the path's extension to know an image is + // previewable; omitting it made every relay-side binary diff empty. + return { ...bufferToBlob(buffer, absPath), missing: false } } catch { - return { content: '', isBinary: false } + // Why: the file exists but could not be read — a read failure, not a deletion. + return { content: '', isBinary: false, missing: false } } } diff --git a/src/renderer/src/components/editor/ImageViewer.tsx b/src/renderer/src/components/editor/ImageViewer.tsx index 408afd795b3..88b57b092b6 100644 --- a/src/renderer/src/components/editor/ImageViewer.tsx +++ b/src/renderer/src/components/editor/ImageViewer.tsx @@ -20,6 +20,7 @@ import { getZoomedImageLayoutSize } from './image-viewer-zoom' import { translate } from '@/i18n/i18n' +import { buildImageDataUri } from '../../../../shared/image-data-uri' const FALLBACK_IMAGE_MIME_TYPE = 'image/png' @@ -59,8 +60,8 @@ export default function ImageViewer({ const isPdf = mimeType === 'application/pdf' const isIntrinsicLayout = layout === 'intrinsic' const previewSrc = useMemo( - () => (cleanedContent && !isPdf ? `data:${mimeType};base64,${cleanedContent}` : null), - [cleanedContent, isPdf, mimeType] + () => buildImageDataUri(mimeType, cleanedContent), + [cleanedContent, mimeType] ) const imageError = previewSrc !== null && failedPreviewSrc === previewSrc const estimatedSize = useMemo(() => { diff --git a/src/renderer/src/components/native-chat/native-chat-runtime-send.ts b/src/renderer/src/components/native-chat/native-chat-runtime-send.ts index f1f97d0b530..a1dca86b65f 100644 --- a/src/renderer/src/components/native-chat/native-chat-runtime-send.ts +++ b/src/renderer/src/components/native-chat/native-chat-runtime-send.ts @@ -23,7 +23,6 @@ export { NATIVE_CHAT_ADVANCE_BUFFER_MS, NATIVE_CHAT_QUESTION_STEP_MS, NATIVE_CHA export const NATIVE_CHAT_IMAGE_ATTACHMENT_SETTLE_MS = 300 - /** Cancels an in-flight send's pending pty writes (the delayed Enter, and any * later question bodies/Enters). Safe to call after the send completes. */ export type NativeChatSendHandle = { diff --git a/src/shared/image-data-uri.test.ts b/src/shared/image-data-uri.test.ts new file mode 100644 index 00000000000..8244d2d652b --- /dev/null +++ b/src/shared/image-data-uri.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, it } from 'vitest' +import { buildImageDataUri } from './image-data-uri' + +describe('buildImageDataUri', () => { + it('builds a data URI from base64 image bytes', () => { + expect(buildImageDataUri('image/png', 'bmV3')).toBe('data:image/png;base64,bmV3') + }) + + it('strips whitespace from line-wrapped base64 payloads', () => { + expect(buildImageDataUri('image/png', 'bm\nV3\t bmV3\r\n')).toBe( + 'data:image/png;base64,bmV3bmV3' + ) + }) + + it('returns null for an empty payload', () => { + expect(buildImageDataUri('image/png', ' \n')).toBeNull() + }) + + it('returns null for a missing mime type', () => { + expect(buildImageDataUri(undefined, 'bmV3')).toBeNull() + }) + + it('returns null for application/pdf (not an source)', () => { + expect(buildImageDataUri('application/pdf', 'JVBER')).toBeNull() + }) + + it('returns null for a non-image mime such as application/octet-stream', () => { + expect(buildImageDataUri('application/octet-stream', 'AAAA')).toBeNull() + }) +}) diff --git a/src/shared/image-data-uri.ts b/src/shared/image-data-uri.ts new file mode 100644 index 00000000000..1501473560f --- /dev/null +++ b/src/shared/image-data-uri.ts @@ -0,0 +1,20 @@ +// Builds an inline `data:` URI for base64 image bytes, shared by the desktop +// editor ImageViewer and the mobile file preview so both decode images the same +// way. Strips whitespace from the payload (base64 from git diffs and SSH streams +// can arrive line-wrapped) and returns null when there is nothing an /RN +// can show — empty content or a non-image mime (PDF, octet-stream, …). +export function buildImageDataUri( + mimeType: string | undefined, + base64Content: string +): string | null { + // Only image/* renders in an /RN ; reject every other mime + // (application/pdf, application/octet-stream, …), not just PDF. + if (!mimeType?.startsWith('image/')) { + return null + } + const cleaned = base64Content.replace(/\s/g, '') + if (!cleaned) { + return null + } + return `data:${mimeType};base64,${cleaned}` +} diff --git a/src/shared/types.ts b/src/shared/types.ts index c805b9a0131..4d4301a0b6f 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -3742,6 +3742,13 @@ export type GitDiffBinaryResult = { isImage?: boolean /** MIME type for binary preview rendering, e.g. "image/png" or "application/pdf" */ mimeType?: string + /** + * True only when the modified side is a proven deletion (working-tree file gone + * or absent from the index) — distinct from an empty modified side caused by a + * read failure or size cap. Lets previewers fall back to the original bytes for + * a deletion without showing a stale image on a failed read. + */ + modifiedDeleted?: boolean } & ( | { originalIsBinary: true; modifiedIsBinary: boolean } | { originalIsBinary: boolean; modifiedIsBinary: true } From ce910e5d523438d1af6903933dc7f628e584328e Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 16 Jul 2026 18:33:34 -0700 Subject: [PATCH 06/24] fix(naming): keep the built-in branch-name prompt general (#9088) * fix(naming): keep the built-in branch-name prompt general The shipped auto-rename prompt no longer hard-codes style rules (word count, kebab-case, no prefixes). Users can already override naming via Source Control AI instructions and the branch-name command template; a prescriptive default fought those overrides. Git-safe sanitization still runs after generation. * fix(naming): preserve branch prompt overrides * chore(skills): refresh rc.2 manifest version --- .../first-work-branch-rename.test.ts | 2 +- .../agent-hooks/first-work-branch-rename.ts | 5 +- src/main/persistence.test.ts | 2 +- .../commit-message-text-generation.test.ts | 10 ++- src/shared/branch-name-from-work.test.ts | 20 +++++- src/shared/branch-name-from-work.ts | 67 ++++++++++--------- src/shared/source-control-ai.test.ts | 16 ++++- src/shared/source-control-ai.ts | 67 ++++++++++++++++--- 8 files changed, 136 insertions(+), 53 deletions(-) diff --git a/src/main/agent-hooks/first-work-branch-rename.test.ts b/src/main/agent-hooks/first-work-branch-rename.test.ts index c1f46a602fb..1df5dbba930 100644 --- a/src/main/agent-hooks/first-work-branch-rename.test.ts +++ b/src/main/agent-hooks/first-work-branch-rename.test.ts @@ -140,7 +140,7 @@ describe('maybeAutoRenameBranchOnFirstWork', () => { }) it('strips a prefix the model leaked into the slug from both branch and display name', async () => { - // Model ignored "no prefixes" and echoed `you/worktree-spinner`, which the + // Model echoed `you/worktree-spinner`, which the // sanitizer folds to `you-worktree-spinner`; without stripping it would // double-prefix the branch (`you/you-...`) and show "You worktree spinner". generateBranchNameMock.mockResolvedValue({ success: true, slug: 'you-worktree-spinner' }) diff --git a/src/main/agent-hooks/first-work-branch-rename.ts b/src/main/agent-hooks/first-work-branch-rename.ts index c6e7fb8d10e..d669192907f 100644 --- a/src/main/agent-hooks/first-work-branch-rename.ts +++ b/src/main/agent-hooks/first-work-branch-rename.ts @@ -283,9 +283,8 @@ async function runAutoRename( const username = provider ? (await getSshGitUsername(provider, repo.path)) || null : (await resolveLocalGitUsername(repo.path)) || null - // The model is told not to add a prefix, but sometimes echoes the configured - // one (e.g. `tmchow/...`); strip it so it doesn't double-prefix the branch or - // leak into the display name. + // The model sometimes echoes the configured prefix (e.g. `tmchow/...`); strip + // it so it doesn't double-prefix the branch or leak into the display name. const slug = stripConfiguredBranchPrefix( generated.slug, getConfiguredBranchPrefix(settings, username) diff --git a/src/main/persistence.test.ts b/src/main/persistence.test.ts index 2cd0c36ef4c..39771208b1d 100644 --- a/src/main/persistence.test.ts +++ b/src/main/persistence.test.ts @@ -2508,7 +2508,7 @@ describe('Store', () => { }) expect(persisted.settings.sourceControlAi.actions.branchName).toEqual({ agentId: 'claude', - commandInputTemplate: '{basePrompt}\n\nRollback commit prompt' + commandInputTemplate: 'Rollback commit prompt\n\n{basePrompt}' }) }) diff --git a/src/main/text-generation/commit-message-text-generation.test.ts b/src/main/text-generation/commit-message-text-generation.test.ts index 4464bb8f4ed..3b921daf65b 100644 --- a/src/main/text-generation/commit-message-text-generation.test.ts +++ b/src/main/text-generation/commit-message-text-generation.test.ts @@ -1853,7 +1853,7 @@ describe('generateBranchNameFromContext', () => { }) }) - it('includes the branch-name custom prompt in the generated prompt', async () => { + it('keeps branch-name guidance first without dropping the output contract', async () => { let prompt = '' await generateBranchNameFromContext( { firstPrompt: 'Fix login flow' }, @@ -1861,7 +1861,8 @@ describe('generateBranchNameFromContext', () => { agentId: 'custom', model: '', customAgentCommand: 'agent', - customPrompt: 'Prefer auth terminology.' + customPrompt: 'Prefer auth terminology.', + commandInputTemplate: 'Prefer auth terminology.\n\n{basePrompt}' }, { kind: 'remote', @@ -1879,8 +1880,11 @@ describe('generateBranchNameFromContext', () => { } ) - expect(prompt).toContain('Additional user prompt:') + expect(prompt.startsWith('Prefer auth terminology.')).toBe(true) expect(prompt).toContain('Prefer auth terminology.') + expect(prompt).not.toContain('Additional user prompt:') + expect(prompt).toContain('Generate a short git branch name') + expect(prompt).toContain('Output ONLY the branch name on a single line') }) }) diff --git a/src/shared/branch-name-from-work.test.ts b/src/shared/branch-name-from-work.test.ts index a30c324cbfe..9b754cd92d1 100644 --- a/src/shared/branch-name-from-work.test.ts +++ b/src/shared/branch-name-from-work.test.ts @@ -100,6 +100,16 @@ describe('buildBranchNamePrompt', () => { expect(prompt).not.toContain("Agent's initial response") }) + it('keeps the default prompt general without style rules', () => { + const prompt = buildBranchNamePrompt({ firstPrompt: 'Add a logout button' }) + expect(prompt).toContain('Generate a short git branch name') + expect(prompt).toContain('Output ONLY the branch name on a single line') + expect(prompt).not.toContain('Rules:') + expect(prompt).not.toMatch(/kebab-case/i) + expect(prompt).not.toMatch(/between \d+ and \d+ words/i) + expect(prompt).not.toMatch(/no prefixes/i) + }) + it('includes the assistant response when present', () => { const prompt = buildBranchNamePrompt({ firstPrompt: 'Add a logout button', @@ -109,12 +119,16 @@ describe('buildBranchNamePrompt', () => { expect(prompt).toContain("I'll wire it into the header.") }) - it('appends a custom branch-name prompt when present', () => { + it('leads with a custom naming prompt so overrides can own style', () => { const prompt = buildBranchNamePrompt( { firstPrompt: 'Add a logout button' }, 'Prefer product nouns.' ) - expect(prompt).toContain('Additional user prompt:') - expect(prompt).toContain('Prefer product nouns.') + expect(prompt.startsWith('Prefer product nouns.')).toBe(true) + expect(prompt).not.toContain('Additional user prompt:') + expect(prompt).not.toContain('Rules:') + expect(prompt).toContain('Generate a git branch name') + expect(prompt).toContain('Output ONLY the branch name on a single line') + expect(prompt).toContain('Add a logout button') }) }) diff --git a/src/shared/branch-name-from-work.ts b/src/shared/branch-name-from-work.ts index abf30f38646..68880b38556 100644 --- a/src/shared/branch-name-from-work.ts +++ b/src/shared/branch-name-from-work.ts @@ -1,10 +1,9 @@ import { MARINE_CREATURES } from './marine-creatures' -// Why: the work-derived branch name stays short on purpose — long, sentence-like -// branches read worse than the creature name they replace. Two-to-four words is -// the sweet spot the feature targets. +// Why: post-generation sanitization still bounds the leaf so a long model dump +// cannot become an unreadable branch. The *prompt* stays general — users can +// override naming style via Source Control AI instructions / templates. export const MAX_BRANCH_NAME_WORDS = 4 -const MIN_BRANCH_NAME_WORDS = 2 const CREATURE_NAMES_LOWER = new Set(MARINE_CREATURES.map((name) => name.toLowerCase())) @@ -46,16 +45,15 @@ export function sanitizeBranchSlug(raw: string, maxWords = MAX_BRANCH_NAME_WORDS } /** - * Drop a leading prefix segment the generation model prepended despite the - * "no prefixes" instruction — e.g. with a `tmchow/` branch prefix the model - * emits `tmchow/worktree-spinner`, which `sanitizeBranchSlug` folds to - * `tmchow-worktree-spinner`. Left alone, that leaks the prefix into both the - * branch leaf (yielding a doubled `tmchow/tmchow-...`) and the humanized - * display name. Strips only when the leading segment matches the *configured* - * prefix, so a work-derived name that merely starts with a real word survives. - * Prefix-only output (the model echoed just `tmchow`) yields an empty slug so - * the caller skips the rename — otherwise it would double-prefix to - * `tmchow/tmchow`. + * Drop a leading prefix segment the generation model sometimes prepends (e.g. + * with a `tmchow/` branch prefix it emits `tmchow/worktree-spinner`, which + * `sanitizeBranchSlug` folds to `tmchow-worktree-spinner`). Left alone, that + * leaks the prefix into both the branch leaf (yielding a doubled + * `tmchow/tmchow-...`) and the humanized display name. Strips only when the + * leading segment matches the *configured* prefix, so a work-derived name that + * merely starts with a real word survives. Prefix-only output (the model + * echoed just `tmchow`) yields an empty slug so the caller skips the rename — + * otherwise it would double-prefix to `tmchow/tmchow`. */ export function stripConfiguredBranchPrefix( slug: string, @@ -100,30 +98,33 @@ export type BranchNameWorkContext = { } /** - * Build the text-generation prompt that asks the configured agent to summarize - * the work into a branch name. Kept in shared so the prompt is identical across - * local and SSH generation targets. + * Build the text-generation prompt that asks the configured agent to name the + * work. Kept in shared so the prompt is identical across local and SSH targets. + * + * Why: the shipped default stays general. Naming style is left open so users + * who override via Source Control AI instructions / the branch-name command + * template are not fighting a rigid built-in rule list. Git-safety (kebab, + * length) is enforced after generation by sanitizeBranchSlug. */ export function buildBranchNamePrompt(context: BranchNameWorkContext, customPrompt = ''): string { - const sections = [ - 'Generate a git branch name that summarizes the coding task described below.', - 'Rules:', - `- Use between ${MIN_BRANCH_NAME_WORDS} and ${MAX_BRANCH_NAME_WORDS} words.`, - '- Lowercase kebab-case only (words joined by single hyphens).', - '- No slashes, no prefixes, no quotes, no trailing punctuation.', - '- Describe the work itself, not the agent or the repository.', - '- Output ONLY the branch name on a single line, nothing else.', - '', - 'User request:', - context.firstPrompt.trim() - ] + const sections: string[] = [] + const prompt = customPrompt.trim() + // Why: when the user supplies naming guidance, lead with it so their + // override owns style rather than sitting under a prescriptive default. + if (prompt) { + sections.push(prompt, '') + } + sections.push( + prompt + ? 'Generate a git branch name that summarizes the coding task described below.' + : 'Generate a short git branch name that summarizes the coding task described below.', + 'Output ONLY the branch name on a single line, nothing else.', + '' + ) + sections.push('User request:', context.firstPrompt.trim()) const assistant = context.assistantMessage?.trim() if (assistant) { sections.push('', "Agent's initial response:", assistant) } - const prompt = customPrompt.trim() - if (prompt) { - sections.push('', 'Additional user prompt:', prompt) - } return sections.join('\n') } diff --git a/src/shared/source-control-ai.test.ts b/src/shared/source-control-ai.test.ts index 2b560889b62..54a35115b87 100644 --- a/src/shared/source-control-ai.test.ts +++ b/src/shared/source-control-ai.test.ts @@ -313,11 +313,19 @@ describe('source-control AI resolution', () => { }).params.customPrompt ).toBe('Repo commit style') expect(resolve('branchName').params.customPrompt).toBe('Global branch style') + expect(resolve('branchName').params.commandInputTemplate).toBe( + 'Global branch style\n\n{basePrompt}' + ) expect( resolve('branchName', { instructionsByOperation: { branchName: 'Repo branch style' } }).params.customPrompt ).toBe('Repo branch style') + expect( + resolve('branchName', { + instructionsByOperation: { branchName: 'Repo branch style' } + }).params.commandInputTemplate + ).toBe('Repo branch style\n\n{basePrompt}') }) it('does not treat null repo instructions as configured overrides', () => { @@ -368,6 +376,12 @@ describe('source-control AI resolution', () => { expect(migrated.instructionsByOperation.commitMessage).toBe('Legacy commit prompt') expect(migrated.instructionsByOperation.pullRequest).toBe('') expect(migrated.instructionsByOperation.branchName).toBe('Legacy commit prompt') + expect(migrated.actions?.commitMessage?.commandInputTemplate).toBe( + '{basePrompt}\n\nLegacy commit prompt' + ) + expect(migrated.actions?.branchName?.commandInputTemplate).toBe( + 'Legacy commit prompt\n\n{basePrompt}' + ) }) it('merges legacy commit-message updates without wiping PR-only settings', () => { @@ -702,7 +716,7 @@ describe('source-control AI resolution', () => { commandInputTemplate: '{basePrompt}' }, branchName: { - commandInputTemplate: '{basePrompt}\n\nbranch style' + commandInputTemplate: 'branch style\n\n{basePrompt}' } }, prCreationDefaults: { diff --git a/src/shared/source-control-ai.ts b/src/shared/source-control-ai.ts index 31adf5ac70d..2ca3541916a 100644 --- a/src/shared/source-control-ai.ts +++ b/src/shared/source-control-ai.ts @@ -296,13 +296,15 @@ export function normalizeRepoSourceControlAiOverrides( const migratedActionOverrides = { ...actionOverrides } for (const operation of SOURCE_CONTROL_TEXT_ACTION_IDS) { const instruction = instructionsByOperation?.[operation] + const existingTemplate = migratedActionOverrides[operation]?.commandInputTemplate if ( typeof instruction === 'string' && - migratedActionOverrides[operation]?.commandInputTemplate === undefined + (existingTemplate === undefined || + isLegacyBranchInstructionTemplate(operation, instruction, existingTemplate)) ) { migratedActionOverrides[operation] = { ...migratedActionOverrides[operation], - commandInputTemplate: commandTemplateFromInstruction(instruction) + commandInputTemplate: commandTemplateFromOperationInstruction(operation, instruction) } } } @@ -324,6 +326,35 @@ function commandTemplateFromInstruction(instruction: string | null | undefined): return ['{basePrompt}', '', trimmed].join('\n') } +function commandTemplateFromOperationInstruction( + operation: SourceControlAiOperation, + instruction: string | null | undefined +): string { + const trimmed = instruction?.trim() + if (!trimmed) { + return '{basePrompt}' + } + // Why: branch naming instructions define naming style, so they must precede + // the general built-in prompt. Other operations retain their released order. + return operation === 'branchName' + ? [trimmed, '', '{basePrompt}'].join('\n') + : commandTemplateFromInstruction(trimmed) +} + +function isLegacyBranchInstructionTemplate( + operation: SourceControlAiOperation, + instruction: string | null | undefined, + template: string | null | undefined +): boolean { + // Why: reorder only the exact template older settings derived automatically; + // a user-authored command template remains authoritative. + return ( + operation === 'branchName' && + Boolean(instruction?.trim()) && + template === commandTemplateFromInstruction(instruction) + ) +} + function actionRecipeFromLegacyCommitMessageAi(legacy: CommitMessageAiSettings): { agentId?: TuiAgent | CustomAgentId | null commandInputTemplate: string @@ -400,7 +431,10 @@ function shouldImportLegacyBranchPrompt( projectedLegacy: CommitMessageAiSettings ): boolean { const branchRecipe = readSourceControlActionDefault(base.actions, 'branchName') - const projectedTemplate = commandTemplateFromInstruction(projectedLegacy.customPrompt) + const projectedTemplate = commandTemplateFromOperationInstruction( + 'branchName', + projectedLegacy.customPrompt + ) return ( branchRecipe.commandInputTemplate === undefined || branchRecipe.commandInputTemplate === @@ -473,7 +507,13 @@ export function sourceControlAiSettingsFromLegacy( actions: { ...defaults.actions, commitMessage: legacyActionRecipe, - branchName: legacyActionRecipe + branchName: { + ...legacyActionRecipe, + commandInputTemplate: commandTemplateFromOperationInstruction( + 'branchName', + legacy.customPrompt + ) + } } } } @@ -640,7 +680,12 @@ export function mergeLegacyCommitMessageAiIntoSourceControlAi( ? applyLegacyAgentToActionRecipe(base.actions?.branchName, legacy.agentId) : base.actions?.branchName), ...(shouldMergeBranchPrompt - ? { commandInputTemplate: legacyActionRecipe.commandInputTemplate } + ? { + commandInputTemplate: commandTemplateFromOperationInstruction( + 'branchName', + legacy.customPrompt + ) + } : {}) } } @@ -693,15 +738,21 @@ export function normalizeSourceControlAiSettings( const existing = readSourceControlActionDefault(normalizedActions, actionId) const instruction = base.instructionsByOperation?.[actionId] const legacyInstruction = actionId === 'commitMessage' ? legacy?.customPrompt : undefined + const resolvedInstruction = instruction ?? legacyInstruction const instructionTemplate = instruction || legacyInstruction - ? commandTemplateFromInstruction(instruction ?? legacyInstruction) + ? commandTemplateFromOperationInstruction(actionId, resolvedInstruction) : undefined const shouldApplyInstructionTemplate = instructionTemplate !== undefined && (existing.commandInputTemplate === undefined || existing.commandInputTemplate === - DEFAULT_SOURCE_CONTROL_ACTION_COMMAND_TEMPLATES[actionId]) + DEFAULT_SOURCE_CONTROL_ACTION_COMMAND_TEMPLATES[actionId] || + isLegacyBranchInstructionTemplate( + actionId, + resolvedInstruction, + existing.commandInputTemplate + )) return [ actionId, { @@ -1048,7 +1099,7 @@ function resolveActionRecipeForTextOperation( ) const fallbackTemplate = repoInstruction !== undefined - ? commandTemplateFromInstruction(repoInstruction) + ? commandTemplateFromOperationInstruction(operation, repoInstruction) : resolveSourceControlActionCommandTemplate(source.actions, operation) const repoTemplate = typeof repoRecipe?.commandInputTemplate === 'string' From 900397369c23f9bde0b5c14c45ebd315c4ffaea9 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 16 Jul 2026 18:33:48 -0700 Subject: [PATCH 07/24] fix(settings): show "Up to date" for current skills in settings sidebar (#9086) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(settings): show "Up to date" for current skills in sidebar Orchestration and Computer Use showed a green "Installed" badge in the settings sidebar even when freshness tracking knows the copy is current. Add an "up-to-date" nav status so a present, no-update skill reads "Up to date" (pairing with the existing "Update available" state), while Voice — which tracks model readiness, not skill freshness — keeps "Installed". * fix(settings): require freshness proof for current badge Only label a skill Up to date when every discovered placement is current. Keep Installed for unloaded, blocked, inaccessible, or unrecognized inventories, and share the classifier with the existing freshness pill. Regenerate the current skill manifest so its app version matches the package release and the required verification gate passes. --- .../src/components/settings/Settings.tsx | 18 +++-- .../settings/SettingsSidebar.test.tsx | 7 ++ .../components/settings/SettingsSidebar.tsx | 4 +- .../skills/SkillFreshnessStatusPill.tsx | 13 ++-- .../src/lib/settings-navigation-types.ts | 7 +- .../skill-freshness-display-status.test.ts | 69 +++++++++++++++++++ .../src/lib/skill-freshness-display-status.ts | 25 +++++++ 7 files changed, 125 insertions(+), 18 deletions(-) create mode 100644 src/renderer/src/lib/skill-freshness-display-status.test.ts create mode 100644 src/renderer/src/lib/skill-freshness-display-status.ts diff --git a/src/renderer/src/components/settings/Settings.tsx b/src/renderer/src/components/settings/Settings.tsx index 0d4c2d5d537..ef50a504a3a 100644 --- a/src/renderer/src/components/settings/Settings.tsx +++ b/src/renderer/src/components/settings/Settings.tsx @@ -12,6 +12,7 @@ import { import { toast } from 'sonner' import type { GlobalSettings, OrcaHooks, ProjectHostSetup, Repo } from '../../../../shared/types' import type { SpeechModelState } from '../../../../shared/speech-types' +import type { SkillFreshnessInventory } from '../../../../shared/skill-freshness' import type { SourceControlAiSettings, SourceControlAiSettingsPatch @@ -101,6 +102,7 @@ import { } from '@/hooks/useInstalledAgentSkills' import { useActiveProjectSkillRuntime } from '@/hooks/useActiveProjectSkillRuntime' import { useSkillFreshness } from '@/hooks/useSkillFreshness' +import { getSkillFreshnessDisplayStatus } from '@/lib/skill-freshness-display-status' import { deriveNeededSectionIds, getInitialMountedSectionIds } from './settings-load-performance' import { translate } from '@/i18n/i18n' import { getProjectHostSetupProjectionFromState } from '../../store/selectors' @@ -206,9 +208,10 @@ function getSettingsNavGroupDefinitionsForSearch( } function getSkillNavInstallStatus(skill: { + name: string installed: boolean loading: boolean - updateAvailable?: boolean + inventory: SkillFreshnessInventory | null }): SettingsNavInstallStatus { if (skill.loading) { return 'checking' @@ -216,7 +219,7 @@ function getSkillNavInstallStatus(skill: { if (!skill.installed) { return 'install' } - return skill.updateAvailable ? 'update-available' : 'installed' + return getSkillFreshnessDisplayStatus(skill.inventory, skill.name) } function hasReadyVoiceModel( @@ -733,16 +736,16 @@ function Settings(): React.JSX.Element { orchestrationSkill const { installed: computerUseSkillInstalled, loading: computerUseSkillLoading } = computerUseSkill - const eligibleUpdateSkillNames = skillFreshnessInventory?.eligibleUpdateNames const capabilityInstallStatusBySectionId = useMemo(() => { - const eligibleUpdates = new Set(skillFreshnessApplies ? (eligibleUpdateSkillNames ?? []) : []) + const applicableFreshnessInventory = skillFreshnessApplies ? skillFreshnessInventory : null const next = new Map([ [ 'orchestration', getSkillNavInstallStatus({ + name: ORCHESTRATION_SKILL_NAME, installed: orchestrationSkillInstalled, loading: orchestrationSkillLoading, - updateAvailable: eligibleUpdates.has(ORCHESTRATION_SKILL_NAME) + inventory: applicableFreshnessInventory }) ] ]) @@ -750,9 +753,10 @@ function Settings(): React.JSX.Element { next.set( 'computer-use', getSkillNavInstallStatus({ + name: COMPUTER_USE_SKILL_NAME, installed: computerUseSkillInstalled, loading: computerUseSkillLoading, - updateAvailable: eligibleUpdates.has(COMPUTER_USE_SKILL_NAME) + inventory: applicableFreshnessInventory }) ) if (settings) { @@ -770,13 +774,13 @@ function Settings(): React.JSX.Element { }, [ computerUseSkillInstalled, computerUseSkillLoading, - eligibleUpdateSkillNames, modelStates, orchestrationSkillInstalled, orchestrationSkillLoading, settings, showDesktopOnlySettings, skillFreshnessApplies, + skillFreshnessInventory, voiceModelStatesLoading ]) const navSections = useMemo( diff --git a/src/renderer/src/components/settings/SettingsSidebar.test.tsx b/src/renderer/src/components/settings/SettingsSidebar.test.tsx index 11eb49163a0..6fec899efea 100644 --- a/src/renderer/src/components/settings/SettingsSidebar.test.tsx +++ b/src/renderer/src/components/settings/SettingsSidebar.test.tsx @@ -62,6 +62,12 @@ function renderSidebar( title: 'Voice', icon: Mic, installStatus: 'installed' + }, + { + id: 'computer-use', + title: 'Computer Use', + icon: Bot, + installStatus: 'up-to-date' } ] }, @@ -114,6 +120,7 @@ describe('SettingsSidebar', () => { expect(markup).toContain('Not installed') expect(markup).toContain('Installed') + expect(markup).toContain('Up to date') expect(markup).toContain('Optional') }) diff --git a/src/renderer/src/components/settings/SettingsSidebar.tsx b/src/renderer/src/components/settings/SettingsSidebar.tsx index 8029eabda1a..54908713bc6 100644 --- a/src/renderer/src/components/settings/SettingsSidebar.tsx +++ b/src/renderer/src/components/settings/SettingsSidebar.tsx @@ -155,6 +155,8 @@ export function SettingsSidebar({ ) case 'installed': return translate('auto.components.settings.AgentSkillSetupPanel.9fcebceb2a', 'Installed') + case 'up-to-date': + return translate('auto.components.skills.SkillFreshnessStatusPill.upToDate', 'Up to date') case 'update-available': return translate( 'auto.components.skills.SkillFreshnessStatusPill.updateAvailable', @@ -167,7 +169,7 @@ export function SettingsSidebar({ const installStatusClassName = (status: SettingsNavInstallStatus): string => cn( 'ml-auto shrink-0 rounded-full border px-1.5 py-0.5 text-[10px] font-medium leading-none', - status === 'installed' + status === 'installed' || status === 'up-to-date' ? 'border-status-success-border bg-status-success-background text-status-success' : status === 'update-available' ? 'border-amber-500/40 bg-amber-500/10 text-amber-700 dark:text-amber-300' diff --git a/src/renderer/src/components/skills/SkillFreshnessStatusPill.tsx b/src/renderer/src/components/skills/SkillFreshnessStatusPill.tsx index 3c858686591..d71bf0d8ba7 100644 --- a/src/renderer/src/components/skills/SkillFreshnessStatusPill.tsx +++ b/src/renderer/src/components/skills/SkillFreshnessStatusPill.tsx @@ -1,6 +1,7 @@ import { useSkillFreshness } from '@/hooks/useSkillFreshness' import { translate } from '@/i18n/i18n' import { IntegrationStatusPill } from '@/components/integration-status-pill' +import { getSkillFreshnessDisplayStatus } from '@/lib/skill-freshness-display-status' // Why: the setup rails' Installed pill is presence-only; when freshness knows a // safe update exists (or that every copy is current) the pill should say so. @@ -8,7 +9,8 @@ import { IntegrationStatusPill } from '@/components/integration-status-pill' // placement is never advertised as updatable here. export function SkillFreshnessStatusPill({ skillName }: { skillName: string }): React.JSX.Element { const { inventory } = useSkillFreshness() - if (inventory?.eligibleUpdateNames.includes(skillName)) { + const status = getSkillFreshnessDisplayStatus(inventory, skillName) + if (status === 'update-available') { return ( {translate( @@ -18,14 +20,7 @@ export function SkillFreshnessStatusPill({ skillName }: { skillName: string }): ) } - const placements = inventory?.installations.filter( - (installation) => installation.name === skillName - ) - if ( - placements && - placements.length > 0 && - placements.every((installation) => installation.status === 'current') - ) { + if (status === 'up-to-date') { return ( {translate('auto.components.skills.SkillFreshnessStatusPill.upToDate', 'Up to date')} diff --git a/src/renderer/src/lib/settings-navigation-types.ts b/src/renderer/src/lib/settings-navigation-types.ts index 3541c0e3f2a..b59ed0f6768 100644 --- a/src/renderer/src/lib/settings-navigation-types.ts +++ b/src/renderer/src/lib/settings-navigation-types.ts @@ -3,7 +3,12 @@ import type { LucideProps } from 'lucide-react' import type { SettingsSearchEntry } from '@/components/settings/settings-search' export type SettingsNavIcon = ComponentType -export type SettingsNavInstallStatus = 'install' | 'installed' | 'update-available' | 'checking' +export type SettingsNavInstallStatus = + | 'install' + | 'installed' + | 'up-to-date' + | 'update-available' + | 'checking' export type SettingsNavTarget = | 'general' diff --git a/src/renderer/src/lib/skill-freshness-display-status.test.ts b/src/renderer/src/lib/skill-freshness-display-status.test.ts new file mode 100644 index 00000000000..9d244007d0e --- /dev/null +++ b/src/renderer/src/lib/skill-freshness-display-status.test.ts @@ -0,0 +1,69 @@ +import { describe, expect, it } from 'vitest' +import type { + SkillFreshnessInstallation, + SkillFreshnessInventory, + SkillFreshnessStatus +} from '../../../shared/skill-freshness' +import { getSkillFreshnessDisplayStatus } from './skill-freshness-display-status' + +const SKILL_NAME = 'orca-cli' + +function placement(status: SkillFreshnessStatus, index = 0): SkillFreshnessInstallation { + return { + id: `${SKILL_NAME}-${index}`, + name: SKILL_NAME, + rootId: 'home-agents', + providers: ['agent-skills'], + sourceKind: 'home', + sourceLabel: 'Agent skills home', + unresolvedPath: `/home/.agents/skills/${SKILL_NAME}-${index}`, + resolvedPath: `/home/.agents/skills/${SKILL_NAME}-${index}`, + physicalIdentity: `physical-${index}`, + topology: 'canonical-copy', + status, + installedReleaseRevision: 1, + installedAppVersion: '1.0.0', + currentReleaseRevision: 2, + currentPackageDigest: 'current', + currentAppVersion: '2.0.0', + observedPackageDigest: status === 'current' ? 'current' : 'other', + errorCategory: null + } +} + +function inventory( + installations: SkillFreshnessInstallation[], + eligibleUpdateNames: string[] = [] +): SkillFreshnessInventory { + return { schemaVersion: 1, installations, eligibleUpdateNames, scannedAt: 1 } +} + +describe('getSkillFreshnessDisplayStatus', () => { + it('shows update available when the inventory authorizes an update', () => { + expect( + getSkillFreshnessDisplayStatus(inventory([placement('outdated')], [SKILL_NAME]), SKILL_NAME) + ).toBe('update-available') + }) + + it('shows up to date only when every discovered placement is current', () => { + expect( + getSkillFreshnessDisplayStatus( + inventory([placement('current'), placement('current', 1)]), + SKILL_NAME + ) + ).toBe('up-to-date') + }) + + it.each([ + ['before the inventory loads', null], + ['when the inventory has no matching placement', inventory([])], + [ + 'when any placement is unrecognized', + inventory([placement('current'), placement('unrecognized', 1)]) + ], + ['when a placement is inaccessible', inventory([placement('inaccessible')])], + ['when an outdated placement is not eligible', inventory([placement('outdated')])] + ])('falls back to installed %s', (_scenario, value) => { + expect(getSkillFreshnessDisplayStatus(value, SKILL_NAME)).toBe('installed') + }) +}) diff --git a/src/renderer/src/lib/skill-freshness-display-status.ts b/src/renderer/src/lib/skill-freshness-display-status.ts new file mode 100644 index 00000000000..25264022659 --- /dev/null +++ b/src/renderer/src/lib/skill-freshness-display-status.ts @@ -0,0 +1,25 @@ +import type { SkillFreshnessInventory } from '../../../shared/skill-freshness' + +export type SkillFreshnessDisplayStatus = 'installed' | 'up-to-date' | 'update-available' + +export function getSkillFreshnessDisplayStatus( + inventory: SkillFreshnessInventory | null, + skillName: string +): SkillFreshnessDisplayStatus { + if (inventory?.eligibleUpdateNames.includes(skillName)) { + return 'update-available' + } + + let hasPlacement = false + for (const installation of inventory?.installations ?? []) { + if (installation.name !== skillName) { + continue + } + hasPlacement = true + // No eligible update is not proof that a blocked or unrecognized copy is current. + if (installation.status !== 'current') { + return 'installed' + } + } + return hasPlacement ? 'up-to-date' : 'installed' +} From f6f25616234da3bd33c3d1ea720b8b33a3c8f6da Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 16 Jul 2026 18:39:11 -0700 Subject: [PATCH 08/24] ci(release): regenerate skill manifest on version bump (#9117) * ci(release): regenerate skill manifest on version bump The release-cut "Bump package.json and tag" step bumped package.json but never regenerated resources/skills/current-manifest.json, so its appVersion stayed at the prior release. That drift shipped in v1.4.144-rc.1, rc.2, and rc.3 (all carried an rc.1 manifest) and turns verify:skill-bundle-manifest red on every branch after a cut, since that check runs in `pnpm lint` and the PR `verify` job. Regenerate the manifest right after `npm version` and stage resources/skills into the release commit so the bundled manifest always matches the shipped version. The generator is dependency-free (node builtins + git), so it runs without a pnpm install, and the step's fetch-depth:0 checkout supplies the tag history it reads. * test(release): guard skill manifest regeneration * test(release): require full history for skill manifest --- .github/workflows/release-cut.yml | 5 ++++- .../package-electron-runtime-contract.test.mjs | 14 +++++++++++++- 2 files changed, 17 insertions(+), 2 deletions(-) diff --git a/.github/workflows/release-cut.yml b/.github/workflows/release-cut.yml index 605645186fb..1409ed1c371 100644 --- a/.github/workflows/release-cut.yml +++ b/.github/workflows/release-cut.yml @@ -472,7 +472,10 @@ jobs: # message and tag name explicitly (avoids npm's `v1.2.3` prefix # assumptions and any lifecycle scripts that would run on bump). npm version "$VERSION" --no-git-tag-version --allow-same-version - git add package.json + # Why: package version is embedded in generated skill provenance; full tag + # history preserves released snapshots while the current version is updated. + node config/scripts/generate-skill-bundle-manifest.mjs --write + git add package.json resources/skills commit_message="release: v$VERSION" if [[ "$EVENT_NAME" == "schedule" ]]; then commit_message="$commit_message [rc-slot:$SLOT]" diff --git a/config/scripts/package-electron-runtime-contract.test.mjs b/config/scripts/package-electron-runtime-contract.test.mjs index 0514453128b..e6f6018e6b0 100644 --- a/config/scripts/package-electron-runtime-contract.test.mjs +++ b/config/scripts/package-electron-runtime-contract.test.mjs @@ -374,16 +374,28 @@ describe('Electron runtime package contract', () => { expect(afterInstallScript).not.toContain('chmod 0755 "$sandbox"') }) - it('lets release-cut tag a version that is already present on main', () => { + it('keeps release-cut version commits self-healing and taggable on retries', () => { const releaseWorkflow = readFileSync( join(projectDir, '.github/workflows/release-cut.yml'), 'utf8' ) const parsedWorkflow = parse(releaseWorkflow) + const checkoutStep = parsedWorkflow.jobs.cut.steps.find((step) => step.name === 'Checkout ref') const bumpStep = parsedWorkflow.jobs.cut.steps.find( (step) => step.name === 'Bump package.json and tag' ) + const bumpIndex = bumpStep.run.indexOf( + 'npm version "$VERSION" --no-git-tag-version --allow-same-version' + ) + const generateIndex = bumpStep.run.indexOf( + 'node config/scripts/generate-skill-bundle-manifest.mjs --write' + ) + const stageIndex = bumpStep.run.indexOf('git add package.json resources/skills') + expect(checkoutStep.with['fetch-depth']).toBe(0) + expect(bumpIndex).toBeGreaterThanOrEqual(0) + expect(generateIndex).toBeGreaterThan(bumpIndex) + expect(stageIndex).toBeGreaterThan(generateIndex) expect(bumpStep.run).toContain('git diff --cached --quiet') expect(bumpStep.run).toContain('git commit --allow-empty -m "$commit_message"') }) From 91ece6ca7edf10472ee47464aaace8d7046d230d Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 16 Jul 2026 18:41:49 -0700 Subject: [PATCH 09/24] fix(terminal): tear down runtime-owned headless PTYs on whole-tab close (#9114) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(terminal): tear down runtime-owned headless PTYs on whole-tab close #8958 placed the closeTerminalTab renderer relay ahead of the authoritative headless teardown in closeMobileSessionTab. Production always wires closeTerminalTab, so the teardown became dead code for whole-parent closes whenever a window is attached. The relay resolves close targets only from renderer state (resolveTerminalCloseTarget), which never contains a runtime-owned headless tab, so it acks success without killing the PTY — leaking SSH-durable/serve PTYs and letting syncMobileSessionTabs republish the "closed" tab (the exact case the removed comment warned about). Run the isRuntimeOwnedHeadlessMobileTab teardown before the relay so headless tabs are killed and pruned authoritatively even with a renderer attached; normal adopted tabs still take the durable closeTerminalTab path. Add a regression test that wires both notifiers (prior tests covered only the no-notifier and in-graph cases). * fix(terminal): preserve adopted tab close guards * fix(runtime): preserve headless close durability on notifier failure --- src/main/runtime/orca-runtime.test.ts | 128 ++++++++++++++++++++++++++ src/main/runtime/orca-runtime.ts | 38 ++++++-- 2 files changed, 157 insertions(+), 9 deletions(-) diff --git a/src/main/runtime/orca-runtime.test.ts b/src/main/runtime/orca-runtime.test.ts index 15a59321409..ba20f682015 100644 --- a/src/main/runtime/orca-runtime.test.ts +++ b/src/main/runtime/orca-runtime.test.ts @@ -19722,6 +19722,134 @@ describe('OrcaRuntimeService', () => { expect(getSession().terminalLayoutsByTabId['host-tab']).toBeUndefined() }) + it('durably tears down a runtime-owned SSH headless tab when renderer cleanup fails', async () => { + // #8958 regression: the acknowledged renderer relay cannot see headless tabs; + // its advisory fallback must not prevent authoritative teardown or flushing. + const persistedPtyId = 'ssh:ssh-1@@relay-pty' + const { runtimeStore, getSession } = makeRuntimeStoreWithWorkspaceSession( + makeWorkspaceSessionWithHeadlessTerminal({ + tabsByWorktree: { + [TEST_WORKTREE_ID]: [ + { + id: 'host-tab', + ptyId: persistedPtyId, + worktreeId: TEST_WORKTREE_ID, + title: 'Remote Terminal', + customTitle: null, + color: null, + sortOrder: 0, + createdAt: 1 + } + ] + }, + terminalLayoutsByTabId: { + 'host-tab': makeHeadlessTerminalLayout({ [HEADLESS_LEAF_ID]: persistedPtyId }) + } + }) + ) + const kill = vi.fn(() => true) + const flushOrThrow = vi.fn() + const rendererError = new Error('renderer unavailable') + const closeTerminal = vi.fn(() => { + throw rendererError + }) + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + const closeTerminalTab = vi.fn(async () => {}) + const runtime = new OrcaRuntimeService({ ...runtimeStore, flushOrThrow } as never) + runtime.setPtyController({ + write: () => true, + kill, + getForegroundProcess: async () => null, + listProcesses: async () => [{ id: persistedPtyId, cwd: TEST_WORKTREE_PATH, title: 'Remote' }] + }) + runtime.setNotifier({ closeTerminal, closeTerminalTab } as never) + runtime.syncWindowGraph(0, { tabs: [], leaves: [] }) + + await runtime.closeMobileSessionTab(`id:${TEST_WORKTREE_ID}`, 'host-tab') + + expect(closeTerminalTab).not.toHaveBeenCalled() + expect(kill).toHaveBeenCalledWith(persistedPtyId) + expect(closeTerminal).toHaveBeenCalledWith('host-tab') + expect(flushOrThrow).toHaveBeenCalledTimes(1) + expect(warn).toHaveBeenCalledWith( + '[runtime] failed to notify renderer after headless terminal close', + { parentTabId: 'host-tab', error: rendererError } + ) + expect(getSession().tabsByWorktree[TEST_WORKTREE_ID]).toEqual([]) + expect(getSession().terminalLayoutsByTabId['host-tab']).toBeUndefined() + }) + + it('keeps the renderer close transaction for an adopted runtime-owned tab', async () => { + // The renderer pin state can be newer than the debounced workspace session. + // Once adopted, its live close guard must win over stale persisted metadata. + const servePtyId = 'serve-adopted-1' + const { runtimeStore, getSession } = makeRuntimeStoreWithWorkspaceSession( + makeWorkspaceSessionWithHeadlessTerminal({ + tabsByWorktree: { + [TEST_WORKTREE_ID]: [ + { + id: 'host-tab', + ptyId: servePtyId, + worktreeId: TEST_WORKTREE_ID, + title: 'Adopted Terminal', + customTitle: null, + color: null, + sortOrder: 0, + createdAt: 1, + isPinned: false + } + ] + }, + terminalLayoutsByTabId: { + 'host-tab': makeHeadlessTerminalLayout({ [HEADLESS_LEAF_ID]: servePtyId }) + } + }) + ) + const kill = vi.fn(() => true) + const closeTerminal = vi.fn() + const closeTerminalTab = vi.fn(async () => { + throw new Error('terminal_tab_pinned') + }) + const runtime = new OrcaRuntimeService(runtimeStore as never) + runtime.setPtyController({ + write: () => true, + kill, + getForegroundProcess: async () => null, + listProcesses: async () => [{ id: servePtyId, cwd: TEST_WORKTREE_PATH, title: 'Adopted' }] + }) + runtime.setNotifier({ closeTerminal, closeTerminalTab } as never) + runtime.syncWindowGraph(1, { + tabs: [ + { + tabId: 'host-tab', + worktreeId: TEST_WORKTREE_ID, + title: 'Adopted Terminal', + activeLeafId: HEADLESS_LEAF_ID, + layout: null + } + ], + leaves: [ + { + tabId: 'host-tab', + worktreeId: TEST_WORKTREE_ID, + leafId: HEADLESS_LEAF_ID, + paneRuntimeId: 1, + ptyId: servePtyId + } + ] + }) + + await expect( + runtime.closeMobileSessionTab(`id:${TEST_WORKTREE_ID}`, 'host-tab') + ).rejects.toThrow('terminal_tab_pinned') + + expect(closeTerminalTab).toHaveBeenCalledWith('host-tab') + expect(closeTerminal).not.toHaveBeenCalled() + expect(kill).not.toHaveBeenCalled() + expect(getSession().tabsByWorktree[TEST_WORKTREE_ID]).toHaveLength(1) + expect(getSession().terminalLayoutsByTabId['host-tab']).toBeDefined() + }) + it('materializes hydrated pending headless terminals with the persisted session identity', async () => { const { runtimeStore } = makeRuntimeStoreWithWorkspaceSession( makeWorkspaceSessionWithHeadlessTerminal() diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index 40bfc0e5691..fdf3657e08e 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -4685,26 +4685,33 @@ export class OrcaRuntimeService { (candidate) => candidate.type === 'terminal' && candidate.parentTabId === tab.parentTabId ).length const closingWholeParent = tab.id !== tabId || parentLeafCount <= 1 + // Why: a runtime-owned headless tab is absent from renderer state, so the + // closeTerminalTab relay below would ack success without killing its PTY, + // and syncMobileSessionTabs would republish the "closed" tab. Only bypass + // the relay when no renderer owns the parent: an adopted tab needs the + // renderer's live pin guard and durable close transaction. + if (closingWholeParent && !this.tabs.has(tab.parentTabId)) { + this.closeHeadlessMobileTerminalTab(worktreeId, snapshot!, tab) + this.notifyRendererOfHeadlessTerminalClose(tab.parentTabId) + this.store?.flushOrThrow?.() + return { closed: true } + } if (closingWholeParent && this.notifier?.closeTerminalTab) { // Why: whole-tab close is a lifecycle transaction. The renderer reply // arrives only after canonical retirement and a forced session flush. await this.notifier.closeTerminalTab(tab.parentTabId) return { closed: true } } - if (!this.notifier?.closeTerminal) { + // Why: notifier implementations without the acknowledged relay may expose + // only raw pane close. Runtime-owned parents still need de-persist + kill. + if (closingWholeParent && this.isRuntimeOwnedHeadlessMobileTab(worktreeId, tab)) { this.closeHeadlessMobileTerminalTab(worktreeId, snapshot!, tab) + this.notifyRendererOfHeadlessTerminalClose(tab.parentTabId) this.store?.flushOrThrow?.() return { closed: true } } - // Why: a runtime-owned headless tab whose whole parent is being closed must - // be torn down authoritatively even with a renderer attached — kill the - // PTY, drop the persisted binding, and prune+emit — or syncMobileSessionTabs - // keeps republishing the "closed" tab with a live PTY. Best-effort notify the - // renderer too so any adopted pane closes (no dead pane). A single split leaf - // (exact id, multi-leaf parent) keeps the per-leaf path so siblings survive. - if (closingWholeParent && this.isRuntimeOwnedHeadlessMobileTab(worktreeId, tab)) { + if (!this.notifier?.closeTerminal) { this.closeHeadlessMobileTerminalTab(worktreeId, snapshot!, tab) - this.notifier?.closeTerminal(tab.parentTabId) this.store?.flushOrThrow?.() return { closed: true } } @@ -4732,6 +4739,19 @@ export class OrcaRuntimeService { return { closed: true } } + private notifyRendererOfHeadlessTerminalClose(parentTabId: string): void { + // Why: this relay is advisory after main owns teardown; renderer failure must + // not prevent the authoritative session flush or turn the close into failure. + try { + this.notifier?.closeTerminal(parentTabId) + } catch (error) { + console.warn('[runtime] failed to notify renderer after headless terminal close', { + parentTabId, + error + }) + } + } + private async closeHeadlessMobileBrowserTab( worktreeId: string, snapshot: RuntimeMobileSessionTabsSnapshot, From 3b227d06e890f98486027bdc09edf1f723440601 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Fri, 17 Jul 2026 01:52:57 +0000 Subject: [PATCH 10/24] release: v1.4.144-rc.4 --- package.json | 2 +- resources/skills/current-manifest.json | 18 +++++++++--------- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/package.json b/package.json index 292873687f1..147a7c89226 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "orca", - "version": "1.4.144-rc.2", + "version": "1.4.144-rc.4", "description": "Next-gen IDE for parallel agentic development", "homepage": "https://github.com/stablyai/orca", "author": "stablyai", diff --git a/resources/skills/current-manifest.json b/resources/skills/current-manifest.json index 51cbb6af5cc..cea063aa334 100644 --- a/resources/skills/current-manifest.json +++ b/resources/skills/current-manifest.json @@ -1,11 +1,11 @@ { "schemaVersion": 1, - "appVersion": "1.4.144-rc.2", + "appVersion": "1.4.144-rc.4", "skills": [ { "name": "computer-use", "sourcePath": "skills/computer-use", - "appVersion": "1.4.144-rc.2", + "appVersion": "1.4.144-rc.4", "releaseRevision": 5, "packageDigest": "cd2809474d57fd7277adb277448e6fa446810d3cbad71ac0b473b9e8ff1bad68", "gitTreeSha": "306c0f8cb63bcac265a5b7975dc2f855be4f1344", @@ -24,7 +24,7 @@ { "name": "linear-tickets", "sourcePath": "skills/linear-tickets", - "appVersion": "1.4.144-rc.2", + "appVersion": "1.4.144-rc.4", "releaseRevision": 4, "packageDigest": "f198d7b22e5ee1673dac403f9cca0553b124e0a90e4fdd05d2c23b7344e32d2b", "gitTreeSha": "de9fc106bbb4e313a90ff9a9513a720909bbd176", @@ -43,7 +43,7 @@ { "name": "orca-cli", "sourcePath": "skills/orca-cli", - "appVersion": "1.4.144-rc.2", + "appVersion": "1.4.144-rc.4", "releaseRevision": 32, "packageDigest": "51740ff13f379ac5743d3fd28a14b17168dcef40f7048c20182dce166098c45f", "gitTreeSha": "ded93000a5f654e2b4f324501282459bd56afe19", @@ -62,7 +62,7 @@ { "name": "orca-emulator", "sourcePath": "skills/orca-emulator", - "appVersion": "1.4.144-rc.2", + "appVersion": "1.4.144-rc.4", "releaseRevision": 4, "packageDigest": "453b1d9aa20b51b8a4d32c7b6def6a93f7ef9c730de32abbcbc1788ad1b1820b", "gitTreeSha": "66be6abe99f1807da85934aee0e22daefc8f7656", @@ -81,7 +81,7 @@ { "name": "orca-emulator-android", "sourcePath": "skills/orca-emulator-android", - "appVersion": "1.4.144-rc.2", + "appVersion": "1.4.144-rc.4", "releaseRevision": 2, "packageDigest": "12272cf82e0731f11e424822b961882457034e730358cc65ea28e4eb9c8ff7f5", "gitTreeSha": "f7b0fc8cbf5cd78ca5156f6bbe3a20f1462d8f83", @@ -100,7 +100,7 @@ { "name": "orca-linear", "sourcePath": "skills/orca-linear", - "appVersion": "1.4.144-rc.2", + "appVersion": "1.4.144-rc.4", "releaseRevision": 2, "packageDigest": "d44d09e6ecb6a64da177083aad26a95f031cd1cf26ba059fdc888c2628aef64f", "gitTreeSha": "c34f42030f43e5a85737996fa375bbd79cb5bea8", @@ -119,7 +119,7 @@ { "name": "orca-per-workspace-env", "sourcePath": "skills/orca-per-workspace-env", - "appVersion": "1.4.144-rc.2", + "appVersion": "1.4.144-rc.4", "releaseRevision": 2, "packageDigest": "fa3b65a1a107fca3f0375c696852477b62f58c154b9eb5c0663c41edc4bcd30d", "gitTreeSha": "354e775b79ea6952ec63acac4d3ee8a9ae07a650", @@ -138,7 +138,7 @@ { "name": "orchestration", "sourcePath": "skills/orchestration", - "appVersion": "1.4.144-rc.2", + "appVersion": "1.4.144-rc.4", "releaseRevision": 24, "packageDigest": "9fbfa2ae3f3f99441563a4b8b1c6302107944480db8718ddb326a862a51f7ab9", "gitTreeSha": "086c41e0b353b4908d2963694b4a7c791d4b3982", From 6e91ca6c0e751b14a27af1b3a8d9d56faaa953ce Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Thu, 16 Jul 2026 19:11:34 -0700 Subject: [PATCH 11/24] fix(browser): local HTTPS Try HTTPS + cert proceed (#8454) (#9104) Co-authored-by: Jinjing <6427696+AmethystLiang@users.noreply.github.com> --- .../browser-local-https-certificate-trust.md | 926 ++++++++++++++++++ src/main/browser/agent-browser-bridge.test.ts | 39 + src/main/browser/agent-browser-bridge.ts | 10 +- .../browser/browser-certificate-challenge.ts | 77 ++ .../browser/browser-certificate-identity.ts | 32 + .../browser-certificate-request-guard.ts | 203 ++++ ...owser-certificate-trust-controller.test.ts | 336 +++++++ .../browser-certificate-trust-controller.ts | 300 ++++++ src/main/browser/browser-manager-grab.test.ts | 4 +- src/main/browser/browser-manager.test.ts | 197 ++++ src/main/browser/browser-manager.ts | 235 ++++- ...owser-session-registry.persistence.test.ts | 4 +- .../browser/browser-session-registry.test.ts | 4 +- src/main/browser/browser-session-registry.ts | 2 + src/main/index.ts | 22 +- src/main/ipc/browser.test.ts | 97 ++ src/main/ipc/browser.ts | 26 +- src/main/runtime/orca-runtime-browser.ts | 13 +- src/main/runtime/orca-runtime.test.ts | 127 +++ src/main/runtime/orca-runtime.ts | 114 ++- src/main/runtime/rpc/methods/browser-core.ts | 11 +- .../runtime/rpc/methods/browser-schemas.ts | 6 +- src/main/runtime/runtime-rpc.test.ts | 21 + src/preload/api-types.ts | 11 +- src/preload/index.ts | 13 +- .../components/browser-pane/BrowserPane.tsx | 305 +++--- .../browser-load-failure-overlay.test.tsx | 319 ++++++ .../browser-load-failure-overlay.tsx | 340 +++++++ .../browser-pane/browser-notices.test.ts | 28 +- .../browser-pane/browser-notices.ts | 44 +- .../tab-create-entry-classifier.test.ts | 9 + .../tab-create-entry-url-classification.ts | 17 +- src/renderer/src/hooks/useIpcEvents.ts | 12 + src/renderer/src/i18n/locales/en.json | 23 + src/renderer/src/i18n/locales/es.json | 23 + src/renderer/src/i18n/locales/ja.json | 23 + src/renderer/src/i18n/locales/ko.json | 23 + src/renderer/src/i18n/locales/zh.json | 23 + .../src/runtime/remote-server-parity.test.ts | 1 + .../sync-runtime-graph-browser.test.ts | 44 +- .../src/runtime/sync-runtime-graph.ts | 6 + .../src/runtime/web-session-tabs-sync.test.ts | 48 +- .../src/runtime/web-session-tabs-sync.ts | 58 +- src/renderer/src/store/slices/browser.test.ts | 33 + src/renderer/src/store/slices/browser.ts | 45 + src/renderer/src/web/web-preload-api.ts | 4 +- src/shared/browser-certificate-errors.ts | 9 + src/shared/browser-url.test.ts | 99 +- src/shared/browser-url.ts | 114 ++- src/shared/protocol-version.ts | 1 + src/shared/runtime-types.ts | 9 + src/shared/types.ts | 22 + ...wser-local-https-certificate-trust.spec.ts | 199 ++++ .../helpers/local-https-test-certificate.ts | 50 + tests/e2e/helpers/local-https-test-server.ts | 162 +++ 55 files changed, 4697 insertions(+), 226 deletions(-) create mode 100644 docs/reference/browser-local-https-certificate-trust.md create mode 100644 src/main/browser/browser-certificate-challenge.ts create mode 100644 src/main/browser/browser-certificate-identity.ts create mode 100644 src/main/browser/browser-certificate-request-guard.ts create mode 100644 src/main/browser/browser-certificate-trust-controller.test.ts create mode 100644 src/main/browser/browser-certificate-trust-controller.ts create mode 100644 src/renderer/src/components/browser-pane/browser-load-failure-overlay.test.tsx create mode 100644 src/renderer/src/components/browser-pane/browser-load-failure-overlay.tsx create mode 100644 src/shared/browser-certificate-errors.ts create mode 100644 tests/e2e/browser-local-https-certificate-trust.spec.ts create mode 100644 tests/e2e/helpers/local-https-test-certificate.ts create mode 100644 tests/e2e/helpers/local-https-test-server.ts diff --git a/docs/reference/browser-local-https-certificate-trust.md b/docs/reference/browser-local-https-certificate-trust.md new file mode 100644 index 00000000000..d858e030cb9 --- /dev/null +++ b/docs/reference/browser-local-https-certificate-trust.md @@ -0,0 +1,926 @@ +# Built-in Browser Local HTTPS and Certificate Trust + +## Status + +Implemented reference design for +[issue #8454](https://github.com/stablyai/orca/issues/8454). + +The branch includes scheme-less local-dev URL classification, certificate-specific +failure copy, `Try HTTPS`, exact per-WebContents certificate grants, +`Proceed Anyway (Unsafe)`, local IPC, remote/headless runtime propagation, and +focused unit and real-Electron coverage. A session-level request gate contains +Chromium's cached certificate continuation so approval cannot leak to sibling +tabs, assets, fetches, iframes, or WebSockets. The release gates below remain +the acceptance contract for Electron upgrades and cross-platform validation. + +## Summary + +Orca's built-in browser cannot open a local HTTPS development server when the +server presents an untrusted certificate. A scheme-less local address also +defaults to HTTP, so entering `localhost:3000` does not discover an HTTPS-only +server. + +This design keeps HTTP as the default for the scheme-less local forms that +already use it, adds a visible `Try HTTPS` recovery action after a failed local +HTTP navigation, and adds an explicit `Proceed Anyway (Unsafe)` path for an +untrusted local HTTPS certificate. Certificate approval is narrow and +temporary: it is bound to one browser WebContents, one secure endpoint, the +SHA-256 digest of the exact leaf certificate, and the specific certificate +error. A secure endpoint treats `https` and its companion `wss` scheme as one +TLS endpoint but still includes the canonical hostname and effective port. No +approval state is persisted. + +The certificate decision runs in the process that owns the browser page. Local +desktop webviews use the desktop main process; SSH/headless pages use the remote +runtime process and expose the decision through runtime RPC. + +## Problem + +There are three independent behaviors behind the current failure. + +1. `normalizeBrowserNavigationUrl` and the tab-create entry classifier prepend + `http://` to scheme-less loopback input. Explicit `https://` input is already + preserved. +2. Electron rejects an untrusted server certificate by default. Orca has no + `certificate-error` decision flow, so a self-signed development certificate + fails with a Chromium certificate error such as + `ERR_CERT_AUTHORITY_INVALID (-202)`. +3. Browser load errors are presented as generic connectivity failures. For a + loopback URL, Orca advises the user to check that the server is running even + when the server responded and only certificate verification failed. + +The same trust limitation exists in the offscreen browser backend used by +headless and SSH-owned browser pages. A desktop-only handler would fix the +visible local webview while leaving remote browser ownership inconsistent. + +## Goals + +- Show certificate-specific failure copy for main-frame certificate errors. +- Let a user explicitly approve an untrusted local development certificate. +- Bind approval to the exact browser surface, secure endpoint, leaf certificate + digest, and certificate error. +- Keep approval in memory only and clear it with the owning browser surface. +- Support both desktop webviews and SSH/headless offscreen browser pages. +- Give scheme-less local input a discoverable path to HTTPS without silently + changing the existing HTTP default. +- Preserve the current browser sandbox, navigation allowlist, session profiles, + and mixed-content policy. +- Keep browser failure state accurate across event-order races, early webview + attachment, reload, stale user actions, and remote latency. + +## Non-goals + +- Do not automatically trust a self-signed certificate. +- Do not disable `webSecurity`, enable insecure mixed content, or add + `--ignore-certificate-errors`. +- Do not install a certificate into the operating-system trust store. +- Do not persist certificate exceptions across app restarts, browser surface + recreation, or session-profile changes. +- Do not default every scheme-less localhost URL to HTTPS. +- Do not automatically probe HTTPS and then downgrade to HTTP. +- Do not add a general-purpose exception flow for public websites in v1. +- Do not make every Chromium certificate error overridable in v1. +- Do not extend localhost worktree-label proxying to HTTPS targets. + +## Product Decisions + +### Scheme-less local input continues to use HTTP + +`localhost:3000` remains `http://localhost:3000/`. HTTP is still the common +development-server default, and changing the inference globally would turn +working HTTP servers into certificate or TLS failures. + +When an HTTP navigation to a loopback URL fails, the browser failure overlay +offers `Try HTTPS`. The action replaces only the URL scheme and preserves the +host, explicit non-default port, path, query, and fragment. An absent port, or +an explicit `:80` normalized away by the URL parser, becomes HTTPS's default +port 443. Orca does not automatically retry because an invisible +cross-protocol retry would add latency, send an unexpected second request, and +make downgrade behavior opaque. + +Explicit input remains authoritative: + +- `http://localhost:3000` stays HTTP. +- `https://localhost:3000` stays HTTPS. +- URLs advertised by a workspace process keep their advertised scheme. + +The shared browser URL domain becomes the source of truth for the existing +scheme-less local-dev classification. The tab-create entry classifier calls +that shared logic instead of keeping a second regular expression and scheme +rule. This consolidation must not reuse the stricter certificate-eligibility +predicate: doing so would change current behavior for inputs such as +`0.0.0.0`, bracketed non-loopback IPv6 addresses, and `.localhost` subdomains. + +### V1 approval covers untrusted local authorities only + +The first version offers `Proceed Anyway (Unsafe)` only when all of these are +true: + +- the failure is for the main frame; +- the attempted URL is HTTPS; +- Electron reports `net::ERR_CERT_AUTHORITY_INVALID`, corresponding to + Chromium load error `-202`; +- the host is an eligible loopback host; +- the WebContents is a browser surface managed by Orca; and +- Orca retained a live pending challenge for that browser surface. + +Other certificate errors receive accurate copy but no bypass action. In +particular, v1 does not bypass revoked certificates, malformed certificates, +weak keys, date errors, or hostname/SAN mismatches. Expanding the allowlist +requires a separate security decision and dedicated tests. + +### Eligible loopback hosts + +The shared predicate accepts: + +- `localhost`, case-insensitively, with an optional trailing dot; +- valid subdomains of `.localhost`, also with an optional trailing dot; +- valid IPv4 addresses in `127.0.0.0/8`; and +- IPv6 loopback `::1`. + +The predicate does not treat `0.0.0.0`, `::`, arbitrary bracketed IPv6 +addresses, private LAN addresses, or a DNS name that merely resolves to +loopback as certificate-bypass eligible. Wildcard bind addresses should already +be normalized to a connectable loopback address before navigation. + +SSH local forwards are eligible when their browser-facing URL uses one of these +loopback hosts. Forwarding does not fix certificate names: a URL using +`127.0.0.1` still needs an IP SAN for `127.0.0.1`; a certificate containing only +`localhost` will fail with `ERR_CERT_COMMON_NAME_INVALID`, which v1 does not +bypass. Custom advertised DNS names remain subject to normal system trust in +v1, even if the name maps to loopback. + +### Approval lifetime + +An accepted grant is scoped to: + +```text +browser WebContents ID + secure endpoint + leaf SHA-256 digest + error name +``` + +The secure endpoint canonicalizes `wss:` to its companion `https:` endpoint and +includes the normalized hostname and effective port. This lets an approved +local page load same-endpoint HTTPS assets and its WSS development socket. A +grant for `https://localhost:3000` does not apply to port 3001, to +`https://127.0.0.1:3000`, to another browser tab, or to a replacement +certificate. + +Construct the secure endpoint from the parsed request URL: map `https:` and +`wss:` to `https:`, lowercase the hostname, remove one optional trailing dot, +use the URL parser's canonical IP representation, and use port 443 when the +parsed port is empty. Exclude credentials, path, query, and fragment. Reject +every other scheme. + +Accepted grants survive reloads and same-surface navigation back to the exact +endpoint. They are cleared when the WebContents commits a main-frame navigation +whose canonical secure endpoint differs from the grant's endpoint (reusing the +per-WebContents navigation-sequence tracking), when the WebContents is destroyed +or replaced, the session profile changes, the browser-owning process exits, or +the app restarts. Binding grant lifetime to the granting main-frame document +prevents a later top-level document in the same tab — for example a navigation +to a public site — from silently reusing the loopback grant for a same-endpoint +iframe, fetch, or WSS subrequest. +Clearing a grant affects future requests; it cannot retroactively terminate a +document that already loaded. Grants are not written to settings, browser +history, workspace session state, or logs. + +Chromium caches an accepted `certificate-error` continuation across the +Electron session partition. Orca therefore retains every endpoint/certificate +identity accepted in that partition until the browser-owning process exits and +gates all later HTTPS/WSS requests with `webRequest.onBeforeRequest`. Only the +WebContents holding the matching grant may reach a cached endpoint. A sibling +main-frame navigation receives a synthetic challenge for the known identity; +sibling subresources, fetches, iframes, workers, and WSS requests fail silently. + +Because a preflight request gate cannot inspect the certificate that will be +presented after Chromium's cache is consulted, one endpoint is locked to the +first accepted untrusted leaf identity for that process lifetime. If that +endpoint later presents a different untrusted leaf, strict verification rejects +it and Orca does not offer another bypass until the browser-owning process +restarts. A replacement system-trusted certificate may load normally; the +conservative request gate still keeps sibling WebContents blocked until restart. + +## User Experience + +### Local HTTP failure + +For a failed `http://` loopback navigation, keep the current connectivity +title and recovery hint. Add `Try HTTPS` when an HTTPS form can be constructed. + +Example: + +```text +Can't reach localhost:3000 +We couldn't connect to your local server. + +[Try HTTPS] [Retry] [Copy Address] [Open Externally] +``` + +`Try HTTPS` is the default button because it is the targeted recovery action +for a local HTTP failure. Keep the existing `Copy Address` recovery action; +`Retry` uses `outline`, while `Copy Address` and `Open Externally` stay quiet +secondary actions. `Try HTTPS` does not appear for an already-HTTPS URL or a +non-eligible host. + +### Certificate failure + +For `ERR_CERT_AUTHORITY_INVALID (-202)` on an eligible local endpoint, replace +the generic connectivity content with: + +```text +Connection isn't secure +Orca doesn't trust the authority that issued the certificate for localhost:3000. + +For local development, use a trusted local certificate when possible. + +[Open Externally] [Retry] [Copy Address] [Proceed Anyway (Unsafe)] +``` + +- `Open Externally` is the default safe recovery action when the URL is + reachable from the desktop. +- When `Open Externally` is unavailable, `Retry` becomes the default action. +- `Proceed Anyway (Unsafe)` uses `outline`; `Copy Address` stays quiet. +- `Proceed Anyway (Unsafe)` is always visibly labeled; it is not hidden behind + a tooltip. +- The unsafe action does not use the destructive color. It does not delete or + irreversibly mutate user data, and the explicit label carries the warning. +- Use a muted `ShieldAlert` icon from `lucide-react`; do not add a warning color + or new token. + +For a remote-owned page whose URL is remote localhost, `Open Externally` is +hidden because the desktop system browser cannot reach the remote loopback +address. It remains available for a desktop-owned page or a locally forwarded +URL. + +For certificate errors that v1 does not permit bypassing, show the same +certificate-specific title with error-specific body text, `Retry`, +`Copy Address`, and an eligible `Open Externally` action. `Copy Address` remains +present as a quiet secondary action in every certificate-failure branch, +eligible or not, so only `Try HTTPS` and `Proceed Anyway (Unsafe)` toggle in and +out across cases. The same default-action rule applies as for `-202`: +`Open Externally` is the default when the URL is reachable from the desktop, +otherwise `Retry` becomes the default. Do not show the local-server-running +hint. + +Use this presentation mapping, inserting the display host into the copy: + +| Chromium code | Body copy | Proceed in v1 | +| ----------------------- | -------------------------------------------------------------------------- | ----------------------------- | +| `-200` | `The certificate doesn't match {host}.` | No | +| `-201` | `The certificate for {host} isn't valid at the current date and time.` | No | +| `-202` | `Orca doesn't trust the authority that issued the certificate for {host}.` | Eligible local endpoints only | +| Other certificate error | `Orca couldn't verify the certificate for {host}.` | No | + +Keep the raw error name/code available to diagnostics and optional details, but +do not make users interpret it to understand the primary failure. + +### Interaction behavior + +- The overlay is persistent inline UI because the user must read and act on the + failure; do not use a toast. +- The overlay does not steal focus when it appears. +- Actions are reachable by Tab and have visible focus rings. +- Use `aria-live="polite"`; repeated background failures must not repeatedly + announce the same message. +- Clicking `Proceed Anyway (Unsafe)` disables the action immediately, and also + disables the other overlay actions (`Retry`, `Try HTTPS`, `Copy Address`, + `Open Externally`) for the duration of the approval round-trip so a stale + click cannot race the controller's own `loadURL`. Re-enable them once a + success or typed-failure response returns. +- Map every `proceedCertificate` failure reason to an overlay outcome: + `expired`, `changed`, `ineligible`, and `missing` keep the overlay open and + show the inline recovery message below; `navigated` shows no message because + the overlay for that navigation is already cleared. In every non-success case + `Proceed Anyway (Unsafe)` stays disabled until a fresh pending challenge is + announced rather than re-enabling on its own. +- Delay the spinner by 200 ms, matching the style guide's remote-latency rule. + After that delay, show the canonical `Loader2` with `Connecting…` and use a + fixed button width so the label swap cannot move adjacent actions. +- If approval expired or the certificate changed, keep the overlay open and + show `The certificate changed or the approval expired. Retry the page.` +- A successful main-frame navigation clears the visible certificate failure. + +The UI must use existing `background`, `foreground`, `muted-foreground`, +`border`, and `ring` tokens plus the existing shadcn `Button` primitive. No new +color, radius, or shadow tier is required. Do not apply a blanket opacity to +the overlay's interactive content; use semantic muted text classes so button +and focus-ring contrast remains intact. Put every new user-visible string +through the existing localization catalog. + +## Architecture + +### Ownership + +The browser-owning main process is the only authority that can accept a +certificate challenge. + +- Desktop-owned `` pages are controlled by the desktop main process. +- Headless/SSH-owned pages are controlled by the remote `orca serve` main + process through `OffscreenBrowserBackend`. +- The renderer only presents a pending challenge and requests approval. It + never decides whether a certificate is trusted. + +### New main-process controller + +Add `src/main/browser/browser-certificate-trust-controller.ts` to manage pending +certificate challenges, and +`src/main/browser/browser-certificate-request-guard.ts` to manage grants plus +the session-level cached-certificate request boundary. + +The controller owns: + +```ts +type BrowserCertificateFailure = { + challengeId: string + browserPageId: string + errorCode: number | null + error: string + origin: string + displayHost: string + canProceed: boolean + observedAt: number +} + +type PendingBrowserCertificateChallenge = { + challengeId: string + guestWebContentsId: number + browserPageId: string | null + navigationSequence: number + navigationUrl: string + origin: string + secureEndpoint: string + leafCertificateSha256: string + errorCode: number | null + error: string + expiresAt: number +} + +type BrowserCertificateGrant = { + guestWebContentsId: number + secureEndpoint: string + leafCertificateSha256: string + error: string +} +``` + +Generate challenge IDs with `randomUUID()`. Compute the certificate identity in +the browser-owning main process as SHA-256 over the DER bytes of Electron's leaf +`certificate.data`; do not depend on the undocumented formatting or algorithm +of `certificate.fingerprint`. Renderer and runtime payloads expose no +certificate bytes or digest—only the challenge ID, display host, origin, error +code, error name, `canProceed`, and observation time. + +Pending challenges expire after five minutes and are bounded to 32 entries. +Accepted grants are bounded to 32 entries per browser-owning process and are +also removed with their WebContents. Bounds evict the oldest entry and notify +the affected UI when a live challenge is evicted. Ordinary cleanup should keep +both collections much smaller. Accepted endpoint identities are not evicted: +Chromium's corresponding cache cannot be cleared narrowly, so forgetting one +would reopen cross-WebContents access until process exit. + +### Certificate event handling + +Register exactly one app-level `certificate-error` listener after Electron is +ready but before either a desktop window or the offscreen backend can create a +browser page. Every branch must call Electron's callback exactly once; an +unexpected exception fails closed with `callback(false)`. + +Electron supplies the certificate error as a string on this event, while +`did-fail-load` supplies the Chromium number later. Normalize the string to one +canonical error name and map only known names to numbers for presentation. An +unknown name keeps `errorCode: null` and is never bypass-eligible. + +For every event: + +1. Resolve the WebContents through `BrowserManager` and reject unmanaged, + retired, or popup WebContents. +2. Parse the request URL, normalize its secure endpoint, and compute the leaf + certificate SHA-256 digest. Invalid data fails closed. +3. Compare the normalized error name, endpoint, and digest against an accepted + grant for this exact WebContents. On an exact match, first retain the accepted + identity in the owning Electron session, then call `event.preventDefault()` + and `callback(true)`. +4. If no grant matches, reject non-main-frame failures without creating a + challenge. Subresources and iframes can consume an existing grant but cannot + mint one. +5. For a main-frame failure, record a pending challenge only when the URL and + error are eligible. Notify the owning page when its page ID is known, then + call `callback(false)`. + +Track a monotonically increasing main-frame navigation sequence per +WebContents. A new main-frame navigation invalidates its pending challenge in +both main and renderer state before the next certificate decision. Repeated +identical events within one sequence reuse the challenge ID; a retry or a +different navigation gets a new ID. This prevents an old button or compromised +renderer from approving a page the user has already left. + +The first failure is always rejected. User approval records a grant and reloads +the page; the next certificate event is the one that succeeds. + +Do not leave Electron's callback pending while waiting for user input. Rejecting +first gives Orca a normal load-failure lifecycle, avoids an unbounded blocked +navigation, and makes stale approval cleanup deterministic. + +Do not use `session.setCertificateVerifyProc`. It applies to an entire session +partition, cannot bind a decision to one browser page, and verification results +can be cached by Chromium's network service. + +### Session request gate and Electron upgrade guard + +The pinned Electron 43 binary caches `callback(true)` broadly enough that a +second WebContents in the same partition can load the endpoint without another +`certificate-error` event. The certificate callback alone is therefore not an +isolation boundary. + +Install exactly one `webRequest.onBeforeRequest` listener on every Orca browser +session before its first page loads. Electron permits only one listener for this +event, so the browser session registry owns installation and removal. Once an +endpoint identity has been accepted, the listener blocks every HTTPS/WSS +request to that endpoint unless `details.webContentsId` has the matching grant. +Requests without a WebContents ID, including background/worker traffic, fail +closed. A blocked main-frame request recreates the challenge and certificate +load error; other resource types are canceled without replacing the current +page's overlay. + +The real-Electron integration suite must prove same-tab reload, same-endpoint +HTTPS assets and WSS, different-tab documents and subresources, and +different-port isolation against the exact Electron version in +`pnpm-lock.yaml`. Any Electron upgrade that breaks those tests blocks release +until the design is revised; unit mocks are insufficient. + +### BrowserManager integration + +Add a narrow BrowserManager query API instead of exposing its internal maps: + +```ts +getManagedBrowserGuestContext(webContentsId: number): { + browserPageId: string | null + worktreeId: string | null + sessionProfileId: string | null + owner: 'desktop-webview' | 'offscreen' +} | null +``` + +For guest lifecycle, follow the existing extracted-controller precedent +(`browser-grab-session-controller.ts`, `browser-guest-ui.ts`, +`browser-download-destination.ts`), all of which BrowserManager drives by +calling a plain method on the controller directly from `registerGuest` / +`unregisterGuest`. Have BrowserManager call +`certificateTrustController.onGuestRegistered({ browserPageId, webContentsId })` +and `.onGuestRetired(webContentsId)` from those same bodies, rather than adding a +new listener/emitter registration API to BrowserManager — there is no such +pub-sub surface in `src/main/browser` today and this single consumer does not +warrant introducing one. + +`getManagedBrowserGuestContext` must recognize an attached primary desktop +guest before renderer registration, returning a null page ID, while rejecting +popup descendants that merely inherited browser policies. It must also +recognize offscreen pages after `registerOffscreenGuest` and stop recognizing a +guest immediately when BrowserManager retires or unregisters it. The controller +re-checks this ownership on every certificate event even if it still has an +in-memory grant. + +An attached desktop webview can encounter a certificate error before its +document reaches `dom-ready`, which is when BrowserPane currently registers the +guest. Keep pending challenges keyed by WebContents ID until registration +supplies the page ID, then flush the failure to the renderer. + +BrowserPane should also register idempotently on the webview's `did-attach` +event, with `dom-ready` retained as a fallback. `registerGuest` should report +whether the main process accepted the registration so a rare attach-policy race +can retry at `dom-ready`; the renderer must not cache the WebContents ID as +registered until it receives `true`. + +Offscreen pages already register before `loadURL`, so their page ID is normally +available when the certificate event fires. Bring their main-frame navigation +and `did-fail-load` observation under the same BrowserManager lifecycle used by +desktop guests; the current offscreen loader only logs a rejected `loadURL`, so +it cannot by itself populate runtime failure snapshots. + +### Renderer state + +Certificate challenges are transient runtime state and must not be added to +the persisted `BrowserPage` schema. + +Add a transient map to the browser store: + +```ts +browserCertificateFailuresByPageId: Record +``` + +The workspace session serializer must omit this map. Clear an entry when: + +- the page completes a successful main-frame navigation; +- the user starts a different navigation; +- the page or workspace closes; +- the owning browser WebContents is replaced; or +- the main process reports that the challenge expired. + +The existing `BrowserLoadError` remains the persisted diagnostic source. Add a +pure Chromium-error classifier for Electron 43's certificate range (`-200` +through `-219`, excluding the unused values) so certificate failures still +render accurate copy after restore even when no live challenge exists. A live +certificate failure's error code takes presentation precedence over a +synthesized `-1` Chromium-error-page fallback. Independently, only a live, +matching challenge controls whether `Proceed Anyway (Unsafe)` is enabled. + +Use both the page ID and attempted origin when combining the live challenge +with `BrowserLoadError`. A stale challenge from a prior navigation must never +add a proceed action to a different failure. Main-process navigation-sequence +invalidation is the security boundary; renderer cleanup is defense in depth and +keeps the UI honest. + +### Local IPC + +Extend the browser preload API with: + +```ts +onCertificateFailureChanged( + callback: (event: { + browserPageId: string + failure: BrowserCertificateFailure | null + }) => void +): () => void + +proceedCertificate(args: { + browserPageId: string + challengeId: string +}): Promise< + | { ok: true } + | { + ok: false + reason: 'expired' | 'changed' | 'ineligible' | 'missing' | 'navigated' + } +> +``` + +Register the handler with the existing browser IPC group and reuse +`isTrustedBrowserRenderer`. The main process resolves the page to its current +WebContents and then consumes the exact pending challenge. Renderer-provided +origin, error code, or certificate identity are never trusted. + +On success, main consumes the challenge, records the grant, and calls +`loadURL` with the challenge's main-owned `navigationUrl`. It must not use +`webContents.getURL()`, which may be `chrome-error://chromewebdata/` or a +previously committed page. It also must not reload if the page's current +navigation sequence no longer matches the challenge. The renderer does not +assign `webview.src` as a second side effect. + +### Remote runtime RPC + +The remote browser owner exposes equivalent behavior through: + +```text +browser.certificate.proceed +``` + +with the runtime worktree selector, remote browser page ID, and challenge ID. +Advertise support through a new `browser.certificate-trust.v1` capability only +when the runtime has both a browser backend and the certificate controller. +Keep the method out of the mobile-scope RPC allowlist; it is for an +authenticated runtime-scope Orca client presenting browser chrome, not a guest +page or unauthenticated caller. + +Extend `RuntimeMobileSessionBrowserTab` compatibly with optional `loadError` +and `certificateFailure` fields. Both are emitted from live runtime state. +`loadError` keeps the existing `BrowserPage` last-known diagnostic semantics, +including workspace persistence; `certificateFailure` is transient and is +never written to workspace persistence. Controller changes, challenge expiry, +navigation, and offscreen `did-fail-load` must mark the runtime session snapshot +dirty so clients do not wait for unrelated state to change. Web clients and +desktop clients reconcile these fields through the existing remote page-handle +mapping. Each full snapshot replaces the prior transient certificate failure +for that page, including clearing it when the field is absent. An older runtime +without the capability always clears any previously mirrored proceed action. + +When the active page has a remote owner, BrowserPane routes approval to that +environment. It must not call local desktop IPC. If the runtime lacks the new +capability, the UI still classifies the certificate failure accurately but does +not render `Proceed Anyway (Unsafe)`. `Try HTTPS`, retry, and other navigation +actions continue through the existing owner-aware navigation path rather than +touching a local webview for a remote-owned page. + +### Scheme recovery + +Add pure functions in the shared browser URL domain: + +```ts +classifySchemeLessLocalDevAddress(rawInput: string): URL | null +isEligibleLocalCertificateHost(hostname: string): boolean +toHttpsRecoveryUrl(rawUrl: string): string | null +``` + +`classifySchemeLessLocalDevAddress` preserves the exact current HTTP-default +input set and is shared by address-bar normalization and tab-create entry. It +is deliberately broader than certificate eligibility. + +`isEligibleLocalCertificateHost` accepts a parsed/canonical hostname; normalize +case and an optional trailing dot and handle the brackets returned by +`URL.hostname` for IPv6. Validate IPv4 octets and DNS label boundaries instead +of using a substring or loose suffix check. + +`toHttpsRecoveryUrl` returns a value only for an HTTP URL with an eligible +loopback host. It changes `protocol` to `https:` on a parsed `URL` object and +preserves credentials, hostname, an explicit non-default port, path, query, and +fragment. When no port is present, including when the parsed HTTP URL normalized +`:80` away, the result uses HTTPS's default port 443. It never probes either +protocol. + +Both address-bar submission and tab-create entry use the shared local-address +classifier. Existing advertised workspace-port URLs remain authoritative and +require no new probing. + +## Security Model + +### Invariants + +- Default certificate verification remains strict. +- Only a trusted Orca renderer or authenticated runtime-scope Orca client can + request approval. +- The renderer cannot choose the origin, certificate digest, error, + WebContents, or eligibility result. +- A grant is exact-match only and never applies across tabs, ports, + certificates, or browser-owning processes. +- Session-cached accepted endpoints remain request-gated until process exit, + even after their original WebContents or bounded grant is removed. +- Certificate PEM/DER data and certificate digests do not enter workspace + state, history, telemetry, or ordinary logs. +- Subresource and iframe certificate failures cannot mint an approval action; + they can consume an already-approved exact-endpoint grant on the same + WebContents. +- The proceed affordance exists only in Orca chrome; guest content has no + direct approval channel. Main-process challenge validation remains the + security boundary and does not assume proof of a physical click. +- A remote client cannot approve a certificate in a different runtime + environment. +- Starting another main-frame navigation invalidates the pending approval in + the browser-owning process, not only in renderer state. + +### Threat cases + +| Threat | Required behavior | +| ----------------------------------------------- | ------------------------------------------------------------------------------------------------------------- | +| Compromised web page sends forged IPC | Guest WebContents has no preload API; trusted-renderer check rejects it. | +| Compromised renderer invents a challenge | Main consumes only a server-created pending challenge tied to the current page and WebContents. | +| Certificate changes after approval | Leaf-digest mismatch rejects; a second untrusted leaf for that endpoint cannot be bypassed until restart. | +| Same host uses a different port | Secure-endpoint mismatch rejects. | +| Another tab visits the approved endpoint | WebContents mismatch rejects. | +| Approved tab later loads a foreign top document | Grant is invalidated when the main frame navigates to a different endpoint; the new document cannot reuse it. | +| Challenge is replayed | Challenge ID is single-use and expires. | +| User leaves the failed page before clicking | Navigation-sequence mismatch rejects without reloading the stale URL. | +| Approved page opens same-endpoint WSS/HMR | Existing endpoint grant permits it; WSS cannot create a grant by itself. | +| Public site presents an untrusted certificate | Host eligibility fails; no proceed action is available. | +| Remote page approval is sent locally | Ownership routing and remote page handles select the browser-owning runtime. | +| App restarts after approval | No persisted grant exists. | + +## Failure Handling + +- If a certificate event arrives before guest registration, retain it by + WebContents ID and flush after registration. +- If guest registration never completes, expire the pending challenge without + notifying unrelated pages. +- If `Proceed` arrives after expiry, navigation, or WebContents replacement, + return a typed failure and leave strict verification in place. +- If reload fails for a different reason, clear the certificate challenge and + present the new load error. +- If the same rejected challenge fires repeatedly in one navigation, coalesce + it without rotating the challenge ID or stacking announcements. +- If the certificate-error listener cannot parse the URL or leaf certificate, + reject normally and do not offer approval. +- If the remote connection drops during approval, keep the local UI stable and + surface the runtime error inline after the latency threshold. +- If an unexpected controller error occurs, call Electron's callback once with + `false`; never leave the request suspended. +- If an accepted main-frame page later opens a new same-endpoint TLS connection, + apply the exact grant to that HTTPS/WSS subrequest. Reject unmatched + subrequests silently without replacing the main-frame overlay. +- If an accepted endpoint presents a different untrusted leaf, reject it without + replacing the process-lifetime accepted identity or offering a second bypass. + +## Data and Privacy + +No migration is required. + +Add no new product analytics in v1. Do not record URLs, certificate digests, +challenge IDs, certificate subjects, or approval results. If product analytics +becomes a requirement later, define coarse actions in the existing typed +feature-interaction catalog as a separate reviewed change. + +Existing diagnostics may retain the Chromium numerical error code, as they do +for other browser load failures. Debug logging must contain at most the error +name, page ID, owner kind, and normalized loopback host class; omit query +strings, full URLs, and certificate digests. + +## Test Plan + +### Shared URL tests + +- Explicit HTTP and HTTPS local URLs remain unchanged. +- Scheme-less `localhost`, IPv4 loopback, and IPv6 loopback still normalize to + HTTP. +- Tab-create and address-bar local-address classification stay in parity for + the full legacy set, including wildcard and bracketed IPv6 inputs. +- The broader scheme-less classifier is not accidentally substituted for the + stricter certificate-eligibility predicate. +- `toHttpsRecoveryUrl` preserves host, an explicit non-default port, path, + query, and fragment; a URL with no parsed port changes from HTTP's default 80 + to HTTPS's default 443. +- `Try HTTPS` is unavailable for HTTPS, file URLs, public hosts, wildcard bind + hosts, private LAN hosts, and invalid URLs. +- Loopback eligibility covers `localhost`, trailing-dot localhost, + `.localhost` subdomains with and without a trailing dot, the full valid IPv4 + 127/8 range, and bracketed or unbracketed `::1` after URL parsing. +- Reject malformed IPv4, arbitrary bracketed IPv6, `0.0.0.0`, `::`, and names + that only contain the word `localhost`. + +### Main-process trust-controller tests + +- Unmanaged, retired, and popup WebContents are rejected without pending + challenges. +- A managed `-202` loopback failure creates one pending challenge and calls + `callback(false)`. +- Every event path calls its callback exactly once; only an accepted grant calls + `preventDefault()` and `callback(true)`. +- The digest is SHA-256 over DER leaf bytes and is stable across PEM formatting. +- An exact accepted grant covers same-endpoint HTTPS assets, WSS, and iframes on + the same WebContents without allowing those requests to mint a challenge. +- Wrong page, WebContents, origin, port, certificate digest, error type, and + challenge ID fail closed. +- A token is single-use and expires after five minutes. +- Repeated identical failures in one navigation coalesce; a new navigation + invalidates the token and rotates the challenge ID. +- Approving after navigation returns `navigated` and never reloads the old URL. +- Approval loads the main-owned challenged URL, not the current Chromium error + URL or a renderer-supplied URL. +- Certificate rotation fails closed and cannot replace the process-lifetime + accepted endpoint identity. +- WebContents destruction removes pending challenges and grants. +- Bounds evict the oldest pending challenge/grant. +- Registering a page after an early certificate event flushes the event to the + correct renderer. +- Concurrent tabs and session profiles remain isolated. + +### IPC and runtime tests + +- Browser certificate IPC rejects untrusted senders and malformed arguments. +- Renderer-supplied origin or certificate-identity fields are neither accepted + nor used. +- Local approval reloads only the currently mapped guest. +- Remote approval calls the owning environment's RPC with the remote page ID. +- Local IPC is not called for a remote-owned page. +- Mobile-scope runtime clients cannot call the approval method. +- Older runtimes without `browser.certificate-trust.v1` never show an enabled + proceed action. +- Runtime snapshots carry live load errors and certificate failure state, + publish immediately on changes, and do not add certificate challenges to + persisted session schemas. +- A snapshot that omits or clears a challenge removes the mirrored proceed + action rather than leaving a stale one behind. +- Two runtime environments with identical page IDs cannot approve each other's + challenge. + +### Renderer tests + +- `-202` renders certificate-specific copy and no server-running hint. +- A live certificate challenge still wins when Chromium's error-page fallback + produced diagnostic code `-1`. +- A live eligible challenge renders `Proceed Anyway (Unsafe)`. +- A restored `-202` load error without a live challenge remains accurate but + cannot proceed. +- Other certificate errors render accurate copy without v1 bypass. +- Local HTTP failures render `Try HTTPS`; HTTPS and non-loopback failures do not. +- Starting a new navigation clears stale certificate state. +- Success clears the overlay. +- Proceed disables immediately, delays the spinner, and preserves its width. +- Expired/changed responses show the inline recovery message. +- Keyboard focus and `aria-live` behavior do not regress the address bar. +- `Copy Address` remains available, interactive content is not blanket-dimmed, + and all new strings are present in the localization catalog. + +### Electron integration + +Run a local HTTPS server with a generated certificate whose SAN includes +`localhost` but whose authority is not trusted. + +1. `https://localhost:` fails with `-202` and shows certificate copy. +2. `Proceed Anyway (Unsafe)` reloads and renders the page. +3. HTTPS assets and a WSS echo/HMR endpoint on the same host and port load after + approval; a different-port asset remains blocked. +4. Reload in the same tab remains allowed without broadening the grant. +5. A second tab using the same session profile, origin, and certificate remains + blocked. This is a release-blocking assertion against the pinned Electron + binary, not a mocked controller test. +6. Navigating away before clicking makes the old challenge unusable and does + not pull the tab back to the failed URL. +7. Replacing the certificate keeps the original tab blocked and offers no + second bypass until the browser-owning process restarts. +8. Closing and recreating the browser surface removes the grant. +9. Restarting Orca removes the grant. +10. A system-trusted local certificate loads without an interstitial. +11. A public/non-loopback untrusted origin has no proceed action. +12. A hostname/SAN mismatch shows certificate copy but no proceed action. +13. `localhost:` first attempts HTTP and its failure offers `Try HTTPS`. + +Repeat the ownership-sensitive cases for: + +- a local desktop webview; +- an SSH local port forward opened in the desktop browser, using a certificate + whose SAN matches the browser-facing forwarded hostname; and +- a headless/offscreen browser page owned by `orca serve` under Linux/Xvfb. + +## Implementation Plan + +1. Add the shared scheme-less local classifier, strict certificate-host + predicate, secure-endpoint canonicalizer, and HTTPS-recovery function with + focused tests. +2. Consolidate address-bar and tab-create local classification without changing + current scheme defaults. +3. Add BrowserManager ownership/registration/retirement hooks, early + `did-attach` registration with a `dom-ready` fallback, and offscreen load + failure observation. +4. Add shared certificate failure/result types and the main-process trust + controller, including navigation-sequence invalidation and the app-level + listener. Its certificate-event handling resolves ownership through the + BrowserManager hooks from step 3, so those must land first. +5. Add local IPC, preload APIs, and transient renderer state. +6. Add the certificate-specific classification and copy as new exported + functions/branches in the existing `browser-notices.ts` (which already owns + load-failure copy via `formatLoadFailureDescription` / + `formatLoadFailureRecoveryHint`), and extract only the overlay JSX into a + focused `browser-load-failure-overlay.tsx` module instead of growing the + already grandfathered `BrowserPane.tsx`; do not add or extend a max-lines + disable. +7. Add certificate-specific and `Try HTTPS` UI, preserve existing recovery + actions, and update the localization catalog. +8. Add the offscreen/runtime capability, live snapshot fields and publication, + and approval RPC. +9. Add unit, IPC, renderer, runtime, and real-Electron integration coverage. +10. Run formatting, typecheck, lint, localization verification, targeted tests, + and cross-platform manual validation. + +## Rollout + +No feature flag is required for accurate error copy or `Try HTTPS` because both +paths preserve strict certificate verification. + +The proceed path should ship only after desktop and offscreen ownership tests +pass. If remote runtime support cannot land in the same release, gate the +button on `browser.certificate-trust.v1`; local desktop support may ship while +older/remote owners retain accurate error copy and trusted-CA guidance. + +Do not close issue #8454 until a packaged build has been validated against a +real self-signed local HTTPS server. Unit mocks alone do not establish Electron +certificate-event ordering. + +## UI Quality Bar + +- Follow `docs/STYLEGUIDE.md` and the adjacent browser failure overlay. +- Use only existing semantic tokens, shadcn buttons, and lucide icons. +- Keep the overlay quiet and monochrome; certificate failure must not introduce + an amber warning treatment. +- Copy must distinguish connection, DNS, and certificate failures without + claiming more than the Chromium error proves. +- Buttons must remain on one line at supported pane widths or wrap as one + deliberate group without overlap. +- Validate light/dark mode, macOS/Windows/Linux font metrics, and 200 ms remote + latency. +- Capture review evidence for local HTTP failure, eligible certificate failure, + ineligible certificate failure, accepted certificate, and the same browser + chrome after recovery. + +## Release Gates and Open Risks + +- The pinned Electron binary must prove that the session request gate blocks a + second tab, sibling HTTPS subresources/WSS, and a second port even when + Chromium does not re-emit `certificate-error`. If any isolation assertion + fails, omit the proceed path or redesign it around a dedicated ephemeral + partition; do not silently widen the grant. +- Same-endpoint WSS and fresh HTTPS subresource connections must work after + approval. A main document that renders while HMR/API traffic remains blocked + does not fix the local-development use case. +- A remote/headless certificate failure must reach the client as both accurate + load-error copy and a live challenge. Logging a rejected offscreen `loadURL` + is not sufficient. +- Stale approval must be rejected in the browser-owning process after any new + main-frame navigation, including when renderer cleanup or a remote snapshot + is delayed. +- SSH-forward validation must use a certificate whose SAN matches the + browser-facing hostname. Authority bypass must not mask a hostname mismatch. +- The app-level listener and real-certificate tests must pass on macOS, Windows, + and Linux/Xvfb. Platform trust-store differences do not justify enabling a + broader error allowlist. + +## Review Decisions and Follow-ups + +### From 2026-07-12 review + +- The integrated browser is part of the development workflow (including + automation, annotations, and remote ownership), so `Open Externally` and trusted + local-certificate guidance remain safer alternatives but do not replace an + explicit in-browser decision. +- V1 intentionally limits approval to `ERR_CERT_AUTHORITY_INVALID (-202)`. + Hostname/SAN, date, revoked, and malformed-certificate failures remain blocked; + broadening that set requires a separate security review and real-certificate + tests. +- Raw error details remain available to existing diagnostics rather than adding a + new disclosure affordance to the quiet recovery overlay. diff --git a/src/main/browser/agent-browser-bridge.test.ts b/src/main/browser/agent-browser-bridge.test.ts index f55f726f356..4629c844f08 100644 --- a/src/main/browser/agent-browser-bridge.test.ts +++ b/src/main/browser/agent-browser-bridge.test.ts @@ -79,6 +79,8 @@ function mockBrowserManager( getWebContentsIdByTabId: () => tabs, getWorktreeIdForTab: (tabId: string) => worktrees.get(tabId), getGuestWebContentsId: vi.fn(() => null), + getBrowserPageLoadError: vi.fn(() => null), + getBrowserPageCertificateFailure: vi.fn(() => null), unregisterGuest: vi.fn(), ensureWebviewVisible: vi.fn(async () => () => {}), acquireAutomationVisibility: vi.fn(async () => () => {}), @@ -531,6 +533,43 @@ describe('AgentBrowserBridge', () => { expect(result.tabs[0].url).toBe('https://a.com') }) + it('surfaces the browser-manager load error on each listed tab', () => { + const tabs = new Map([['tab-a', 1]]) + const wc1 = mockWebContents(1, 'chrome-error://chromewebdata/', '') + webContentsFromIdMock.mockImplementation((id: number) => (id === 1 ? wc1 : null)) + const loadError = { + code: -202, + description: 'ERR_CERT_AUTHORITY_INVALID', + validatedUrl: 'https://localhost:3443/' + } + const certificateFailure = { + challengeId: 'challenge-1', + browserPageId: 'tab-a', + errorCode: -202, + error: 'ERR_CERT_AUTHORITY_INVALID', + origin: 'https://localhost:3443', + displayHost: 'localhost:3443', + canProceed: true, + observedAt: 123 + } + const b = new AgentBrowserBridge( + mockBrowserManager(tabs, new Map(), { + getBrowserPageLoadError: vi.fn((tabId: string) => (tabId === 'tab-a' ? loadError : null)), + getBrowserPageCertificateFailure: vi.fn((tabId: string) => + tabId === 'tab-a' ? certificateFailure : null + ) + }) + ) + + // Why: an agent driving the browser must see the structured cert failure, + // not just chrome-error:// from getURL(). + expect(b.tabList().tabs[0]).toMatchObject({ + url: 'https://localhost:3443/', + loadError, + certificateFailure + }) + }) + it('does not mutate active-tab routing when tab-list infers the first live tab', () => { const tabs = new Map([ ['tab-a', 1], diff --git a/src/main/browser/agent-browser-bridge.ts b/src/main/browser/agent-browser-bridge.ts index 80817c81d58..0bec7bf68fa 100644 --- a/src/main/browser/agent-browser-bridge.ts +++ b/src/main/browser/agent-browser-bridge.ts @@ -698,12 +698,18 @@ export class AgentBrowserBridge { if (firstLiveWcId === null) { firstLiveWcId = wcId } + const loadError = this.browserManager.getBrowserPageLoadError(tabId) + const certificateFailure = this.browserManager.getBrowserPageCertificateFailure(tabId) result.push({ browserPageId: tabId, index: index++, - url: wc.getURL() ?? '', + // Why: failed WebContents report chrome-error://, which is neither + // actionable nor the address the user asked to load. + url: loadError?.validatedUrl ?? wc.getURL() ?? '', title: wc.getTitle() ?? '', - active: wcId === activeWcId + active: wcId === activeWcId, + loadError, + certificateFailure }) } // Why: if no tab has been explicitly activated yet, surface the first live diff --git a/src/main/browser/browser-certificate-challenge.ts b/src/main/browser/browser-certificate-challenge.ts new file mode 100644 index 00000000000..2e49685b5d3 --- /dev/null +++ b/src/main/browser/browser-certificate-challenge.ts @@ -0,0 +1,77 @@ +import type { BrowserCertificateFailure } from '../../shared/types' +import { SUPPORTED_CERTIFICATE_ERROR_CODE } from './browser-certificate-identity' + +export const CERTIFICATE_CHALLENGE_TTL_MS = 5 * 60_000 +export const MAX_PENDING_CERTIFICATE_CHALLENGES = 32 +export const MAX_CERTIFICATE_GRANTS = 32 + +export type ManagedBrowserGuestContext = { + browserPageId: string | null + worktreeId: string | null + sessionProfileId: string | null + owner: 'desktop-webview' | 'offscreen' +} + +export type BrowserCertificateTrustControllerDependencies = { + resolveManagedGuestContext: (webContentsId: number) => ManagedBrowserGuestContext | null + resolveWebContentsIdForPage: (browserPageId: string) => number | null + resolveWebContents: (webContentsId: number) => Electron.WebContents | null + onFailureChanged: ( + webContentsId: number, + failure: BrowserCertificateFailure | null, + navigationUrl?: string + ) => void + now?: () => number + createChallengeId?: () => string +} + +export type PendingCertificateChallenge = { + challengeId: string + guestWebContentsId: number + browserPageId: string | null + navigationSequence: number + navigationUrl: string + origin: string + displayHost: string + secureEndpoint: string + leafCertificateSha256: string + errorCode: number | null + error: string + observedAt: number + expiresAt: number +} + +export type CertificateTrustGrant = Pick< + PendingCertificateChallenge, + 'guestWebContentsId' | 'secureEndpoint' | 'leafCertificateSha256' | 'error' +> + +export function certificateChallengeIdentityMatches( + challenge: PendingCertificateChallenge, + candidate: Pick< + PendingCertificateChallenge, + 'navigationSequence' | 'secureEndpoint' | 'leafCertificateSha256' | 'error' + > +): boolean { + return ( + challenge.navigationSequence === candidate.navigationSequence && + challenge.secureEndpoint === candidate.secureEndpoint && + challenge.leafCertificateSha256 === candidate.leafCertificateSha256 && + challenge.error === candidate.error + ) +} + +export function toBrowserCertificateFailure( + challenge: PendingCertificateChallenge +): BrowserCertificateFailure { + return { + challengeId: challenge.challengeId, + browserPageId: challenge.browserPageId ?? '', + errorCode: challenge.errorCode, + error: challenge.error, + origin: challenge.origin, + displayHost: challenge.displayHost, + canProceed: challenge.errorCode === SUPPORTED_CERTIFICATE_ERROR_CODE, + observedAt: challenge.observedAt + } +} diff --git a/src/main/browser/browser-certificate-identity.ts b/src/main/browser/browser-certificate-identity.ts new file mode 100644 index 00000000000..9170f8edde4 --- /dev/null +++ b/src/main/browser/browser-certificate-identity.ts @@ -0,0 +1,32 @@ +import { createHash } from 'node:crypto' + +export const SUPPORTED_CERTIFICATE_ERROR = 'ERR_CERT_AUTHORITY_INVALID' +export const SUPPORTED_CERTIFICATE_ERROR_CODE = -202 + +export function normalizeCertificateError(error: string): string { + return error + .trim() + .replace(/^net::/i, '') + .toUpperCase() +} + +export function getSupportedCertificateErrorCode(error: string): number | null { + return normalizeCertificateError(error) === SUPPORTED_CERTIFICATE_ERROR + ? SUPPORTED_CERTIFICATE_ERROR_CODE + : null +} + +export function getLeafCertificateSha256(certificate: Electron.Certificate): string | null { + const match = certificate.data.match( + /-----BEGIN CERTIFICATE-----([\s\S]*?)-----END CERTIFICATE-----/ + ) + if (!match) { + return null + } + try { + const der = Buffer.from(match[1].replace(/\s+/g, ''), 'base64') + return der.length > 0 ? createHash('sha256').update(der).digest('hex') : null + } catch { + return null + } +} diff --git a/src/main/browser/browser-certificate-request-guard.ts b/src/main/browser/browser-certificate-request-guard.ts new file mode 100644 index 00000000000..0a4602804a9 --- /dev/null +++ b/src/main/browser/browser-certificate-request-guard.ts @@ -0,0 +1,203 @@ +import type { Session } from 'electron' + +import { toSecureCertificateEndpoint } from '../../shared/browser-url' +import { MAX_CERTIFICATE_GRANTS, type CertificateTrustGrant } from './browser-certificate-challenge' + +type CertificateIdentity = Pick< + CertificateTrustGrant, + 'secureEndpoint' | 'leafCertificateSha256' | 'error' +> + +type BlockedMainFrame = CertificateIdentity & { + webContentsId: number + navigationUrl: string + origin: string + displayHost: string +} + +type RequestGuardDependencies = { + onBlockedMainFrame: (blocked: BlockedMainFrame) => void +} + +function certificateIdentitiesMatch( + left: CertificateIdentity | undefined, + right: CertificateIdentity +): boolean { + return Boolean( + left && + left.secureEndpoint === right.secureEndpoint && + left.leafCertificateSha256 === right.leafCertificateSha256 && + left.error === right.error + ) +} + +export class BrowserCertificateRequestGuard { + private readonly grantsByGuestId = new Map() + private readonly grantSessionByGuestId = new Map() + private readonly guardedSessions = new Set() + private readonly acceptedIdentityBySession = new Map>() + + constructor(private readonly dependencies: RequestGuardDependencies) {} + + installSession(session: Session): void { + if (this.guardedSessions.has(session)) { + return + } + // Why: Chromium caches certificate continuations at session scope. This + // request gate restores the narrower per-WebContents approval boundary. + session.webRequest.onBeforeRequest((details, callback) => { + callback(this.shouldBlockRequest(session, details) ? { cancel: true } : {}) + }) + this.guardedSessions.add(session) + } + + removeSession(session: Session): void { + if (!this.guardedSessions.delete(session)) { + return + } + session.webRequest.onBeforeRequest(null) + this.acceptedIdentityBySession.delete(session) + for (const [webContentsId, grantSession] of this.grantSessionByGuestId) { + if (grantSession === session) { + this.revokeGuest(webContentsId) + } + } + } + + canOfferCertificate(session: Session, identity: CertificateIdentity): boolean { + if (!this.guardedSessions.has(session)) { + return false + } + const accepted = this.acceptedIdentityBySession.get(session)?.get(identity.secureEndpoint) + // Why: the request gate cannot inspect TLS after Chromium caches a decision. + // Never accept a second bad leaf for one endpoint in the same app process. + return !accepted || certificateIdentitiesMatch(accepted, identity) + } + + grant(session: Session, grant: CertificateTrustGrant): boolean { + if (!this.guardedSessions.has(session)) { + return false + } + let acceptedByEndpoint = this.acceptedIdentityBySession.get(session) + const accepted = acceptedByEndpoint?.get(grant.secureEndpoint) + // Why: once one leaf is accepted for an endpoint in this session, Chromium + // caches the TLS decision. Refuse a conflicting pending leaf so proceed() + // cannot report success when the next certificate-error will still fail. + if (accepted && !certificateIdentitiesMatch(accepted, grant)) { + return false + } + // Why: pin the identity at grant time, not only on the later certificate + // callback, so a concurrent sibling proceed cannot race in a second leaf. + if (!acceptedByEndpoint) { + acceptedByEndpoint = new Map() + this.acceptedIdentityBySession.set(session, acceptedByEndpoint) + } + acceptedByEndpoint.set(grant.secureEndpoint, { + secureEndpoint: grant.secureEndpoint, + leafCertificateSha256: grant.leafCertificateSha256, + error: grant.error + }) + this.grantsByGuestId.delete(grant.guestWebContentsId) + this.grantsByGuestId.set(grant.guestWebContentsId, grant) + this.grantSessionByGuestId.set(grant.guestWebContentsId, session) + this.enforceGrantBound() + return true + } + + shouldTrustCertificate( + session: Session, + webContentsId: number, + identity: CertificateIdentity + ): boolean { + if ( + !this.guardedSessions.has(session) || + this.grantSessionByGuestId.get(webContentsId) !== session || + !certificateIdentitiesMatch(this.grantsByGuestId.get(webContentsId), identity) + ) { + return false + } + let acceptedByEndpoint = this.acceptedIdentityBySession.get(session) + if (!acceptedByEndpoint) { + acceptedByEndpoint = new Map() + this.acceptedIdentityBySession.set(session, acceptedByEndpoint) + } + const accepted = acceptedByEndpoint.get(identity.secureEndpoint) + if (accepted && !certificateIdentitiesMatch(accepted, identity)) { + return false + } + acceptedByEndpoint.set(identity.secureEndpoint, identity) + return true + } + + revokeGuest(webContentsId: number): void { + this.grantsByGuestId.delete(webContentsId) + this.grantSessionByGuestId.delete(webContentsId) + } + + revokeForCommittedNavigation(webContentsId: number, url: string): void { + const grant = this.grantsByGuestId.get(webContentsId) + if (grant && toSecureCertificateEndpoint(url) !== grant.secureEndpoint) { + this.revokeGuest(webContentsId) + } + } + + private shouldBlockRequest( + session: Session, + details: Electron.OnBeforeRequestListenerDetails + ): boolean { + const secureEndpoint = toSecureCertificateEndpoint(details.url) + if (!secureEndpoint) { + return false + } + const accepted = this.acceptedIdentityBySession.get(session)?.get(secureEndpoint) + if (!accepted) { + return false + } + const webContentsId = details.webContentsId ?? details.webContents?.id + const grant = webContentsId === undefined ? undefined : this.grantsByGuestId.get(webContentsId) + if ( + webContentsId !== undefined && + grant && + this.grantSessionByGuestId.get(webContentsId) === session && + certificateIdentitiesMatch(grant, accepted) + ) { + return false + } + if (details.resourceType === 'mainFrame' && webContentsId !== undefined) { + this.reportBlockedMainFrame(webContentsId, details.url, accepted) + } + return true + } + + private reportBlockedMainFrame( + webContentsId: number, + navigationUrl: string, + identity: CertificateIdentity + ): void { + try { + const parsed = new URL(navigationUrl) + if (parsed.protocol !== 'https:') { + return + } + this.dependencies.onBlockedMainFrame({ + webContentsId, + navigationUrl, + origin: parsed.origin, + displayHost: parsed.host, + ...identity + }) + } catch { + // The request remains blocked even if Chromium supplies a malformed URL. + } + } + + private enforceGrantBound(): void { + while (this.grantsByGuestId.size > MAX_CERTIFICATE_GRANTS) { + const oldest = this.grantsByGuestId.keys().next().value as number | undefined + if (oldest === undefined) { + return + } + this.revokeGuest(oldest) + } + } +} diff --git a/src/main/browser/browser-certificate-trust-controller.test.ts b/src/main/browser/browser-certificate-trust-controller.test.ts new file mode 100644 index 00000000000..85153aef5a7 --- /dev/null +++ b/src/main/browser/browser-certificate-trust-controller.test.ts @@ -0,0 +1,336 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +import { BrowserCertificateTrustController } from './browser-certificate-trust-controller' + +const FIRST_CERTIFICATE = certificate('first certificate') +const SECOND_CERTIFICATE = certificate('replacement certificate') +let beforeRequestListener: + | (( + details: Electron.OnBeforeRequestListenerDetails, + callback: (response: Electron.CallbackResponse) => void + ) => void) + | null = null +const browserSession = { + webRequest: { + onBeforeRequest: vi.fn( + ( + listener: + | (( + details: Electron.OnBeforeRequestListenerDetails, + callback: (response: Electron.CallbackResponse) => void + ) => void) + | null + ) => { + beforeRequestListener = listener + } + ) + } +} as unknown as Electron.Session + +function certificate(contents: string): Electron.Certificate { + return { + data: `-----BEGIN CERTIFICATE-----\n${Buffer.from(contents).toString('base64')}\n-----END CERTIFICATE-----` + } as Electron.Certificate +} + +function createGuest(id: number): Electron.WebContents { + return { + id, + session: browserSession, + isDestroyed: vi.fn(() => false), + loadURL: vi.fn(() => Promise.resolve()) + } as unknown as Electron.WebContents +} + +function beforeRequest(args: { + url?: string + webContentsId?: number + resourceType?: Electron.OnBeforeRequestListenerDetails['resourceType'] +}) { + const callback = vi.fn() + beforeRequestListener?.( + { + id: 1, + url: args.url ?? 'https://localhost:3443/app', + method: 'GET', + webContentsId: args.webContentsId, + resourceType: args.resourceType ?? 'mainFrame', + referrer: '', + timestamp: 1, + uploadData: [] + }, + callback + ) + return callback +} + +function certificateEvent(args: { + controller: BrowserCertificateTrustController + guest: Electron.WebContents + url?: string + error?: string + certificate?: Electron.Certificate + isMainFrame?: boolean +}) { + const preventDefault = vi.fn() + const callback = vi.fn() + args.controller.handleCertificateError({ + event: { preventDefault }, + webContents: args.guest, + url: args.url ?? 'https://localhost:3443/app', + error: args.error ?? 'net::ERR_CERT_AUTHORITY_INVALID', + certificate: args.certificate ?? FIRST_CERTIFICATE, + callback, + isMainFrame: args.isMainFrame ?? true + }) + return { preventDefault, callback } +} + +describe('BrowserCertificateTrustController', () => { + const guest = createGuest(7) + const otherGuest = createGuest(8) + const onFailureChanged = vi.fn() + let now = 1_000 + let challengeNumber = 0 + let pageByGuestId: Map + let guestByPageId: Map + let guestById: Map + let controller: BrowserCertificateTrustController + + beforeEach(() => { + vi.clearAllMocks() + vi.mocked(guest.isDestroyed).mockReturnValue(false) + vi.mocked(otherGuest.isDestroyed).mockReturnValue(false) + vi.mocked(guest.loadURL).mockResolvedValue(undefined) + vi.mocked(otherGuest.loadURL).mockResolvedValue(undefined) + now = 1_000 + challengeNumber = 0 + pageByGuestId = new Map([ + [guest.id, 'page-1'], + [otherGuest.id, 'page-2'] + ]) + guestByPageId = new Map([ + ['page-1', guest.id], + ['page-2', otherGuest.id] + ]) + guestById = new Map([ + [guest.id, guest], + [otherGuest.id, otherGuest] + ]) + controller = new BrowserCertificateTrustController({ + resolveManagedGuestContext: (webContentsId) => { + if (!pageByGuestId.has(webContentsId)) { + return null + } + return { + browserPageId: pageByGuestId.get(webContentsId) ?? null, + worktreeId: 'worktree-1', + sessionProfileId: 'profile-1', + owner: 'desktop-webview' + } + }, + resolveWebContentsIdForPage: (browserPageId) => guestByPageId.get(browserPageId) ?? null, + resolveWebContents: (webContentsId) => guestById.get(webContentsId) ?? null, + onFailureChanged, + now: () => now, + createChallengeId: () => `challenge-${++challengeNumber}` + }) + controller.installSessionRequestGuard(browserSession) + }) + + it('rejects first, then trusts only the approved guest, endpoint, certificate, and error', () => { + controller.onMainFrameNavigationStarted(guest.id) + const first = certificateEvent({ controller, guest }) + + expect(first.callback).toHaveBeenCalledOnce() + expect(first.callback).toHaveBeenCalledWith(false) + expect(first.preventDefault).not.toHaveBeenCalled() + expect(controller.getFailure('page-1')).toMatchObject({ + challengeId: 'challenge-1', + browserPageId: 'page-1', + origin: 'https://localhost:3443', + displayHost: 'localhost:3443', + errorCode: -202, + canProceed: true + }) + + expect(controller.proceed('page-1', 'challenge-1')).toEqual({ ok: true }) + expect(guest.loadURL).toHaveBeenCalledWith('https://localhost:3443/app') + expect(onFailureChanged).toHaveBeenLastCalledWith(guest.id, null) + + const sameEndpoint = certificateEvent({ + controller, + guest, + url: 'wss://localhost:3443/socket' + }) + expect(sameEndpoint.preventDefault).toHaveBeenCalledOnce() + expect(sameEndpoint.callback).toHaveBeenCalledOnce() + expect(sameEndpoint.callback).toHaveBeenCalledWith(true) + + const otherTab = certificateEvent({ controller, guest: otherGuest }) + expect(otherTab.callback).toHaveBeenCalledWith(false) + expect(otherTab.preventDefault).not.toHaveBeenCalled() + + const otherPort = certificateEvent({ + controller, + guest, + url: 'https://localhost:3444/' + }) + expect(otherPort.callback).toHaveBeenCalledWith(false) + expect(otherPort.preventDefault).not.toHaveBeenCalled() + + const otherCertificate = certificateEvent({ + controller, + guest, + certificate: SECOND_CERTIFICATE + }) + expect(otherCertificate.callback).toHaveBeenCalledWith(false) + expect(otherCertificate.preventDefault).not.toHaveBeenCalled() + }) + + it('blocks a session-cached certificate for sibling requests until that guest approves it', () => { + certificateEvent({ controller, guest }) + expect(controller.proceed('page-1', 'challenge-1')).toEqual({ ok: true }) + expect(certificateEvent({ controller, guest }).callback).toHaveBeenCalledWith(true) + + const siblingMainFrame = beforeRequest({ webContentsId: otherGuest.id }) + expect(siblingMainFrame).toHaveBeenCalledWith({ cancel: true }) + expect(controller.getFailure('page-2')).toMatchObject({ + challengeId: 'challenge-2', + browserPageId: 'page-2', + origin: 'https://localhost:3443', + canProceed: true + }) + + expect( + beforeRequest({ webContentsId: otherGuest.id, resourceType: 'webSocket' }) + ).toHaveBeenCalledWith({ cancel: true }) + expect(beforeRequest({ resourceType: 'webSocket' })).toHaveBeenCalledWith({ cancel: true }) + expect( + beforeRequest({ url: 'https://localhost:3444/app', webContentsId: otherGuest.id }) + ).toHaveBeenCalledWith({}) + expect(beforeRequest({ webContentsId: guest.id })).toHaveBeenCalledWith({}) + + expect(controller.proceed('page-2', 'challenge-2')).toEqual({ ok: true }) + expect(beforeRequest({ webContentsId: otherGuest.id })).toHaveBeenCalledWith({}) + }) + + it('fails closed when an accepted endpoint presents a replacement bad certificate', () => { + certificateEvent({ controller, guest }) + expect(controller.proceed('page-1', 'challenge-1')).toEqual({ ok: true }) + expect(certificateEvent({ controller, guest }).callback).toHaveBeenCalledWith(true) + + controller.onMainFrameNavigationStarted(guest.id) + const replacement = certificateEvent({ controller, guest, certificate: SECOND_CERTIFICATE }) + + expect(replacement.callback).toHaveBeenCalledWith(false) + expect(replacement.preventDefault).not.toHaveBeenCalled() + expect(controller.getFailure('page-1')).toBeNull() + }) + + it('rejects proceed for a second tab whose pending leaf conflicts with an accepted identity', () => { + // Both tabs observe different leaves before either is approved. + certificateEvent({ controller, guest }) + certificateEvent({ controller, guest: otherGuest, certificate: SECOND_CERTIFICATE }) + expect(controller.getFailure('page-1')?.challengeId).toBe('challenge-1') + expect(controller.getFailure('page-2')?.challengeId).toBe('challenge-2') + + expect(controller.proceed('page-1', 'challenge-1')).toEqual({ ok: true }) + // First proceed pins the session identity; the sibling's conflicting leaf + // must not report success (Chromium cannot honor a second bad leaf). + expect(controller.proceed('page-2', 'challenge-2')).toEqual({ + ok: false, + reason: 'ineligible' + }) + expect(otherGuest.loadURL).not.toHaveBeenCalled() + }) + + it('never offers approval for public hosts, non-authority failures, or subframes', () => { + for (const event of [ + certificateEvent({ controller, guest, url: 'https://example.com/' }), + certificateEvent({ controller, guest, error: 'ERR_CERT_DATE_INVALID' }), + certificateEvent({ controller, guest, isMainFrame: false }) + ]) { + expect(event.callback).toHaveBeenCalledOnce() + expect(event.callback).toHaveBeenCalledWith(false) + expect(event.preventDefault).not.toHaveBeenCalled() + } + expect(controller.getFailure('page-1')).toBeNull() + expect(onFailureChanged).not.toHaveBeenCalled() + }) + + it('queues an early challenge until the guest receives its browser page identity', () => { + pageByGuestId.set(guest.id, null) + certificateEvent({ controller, guest }) + + expect(onFailureChanged).not.toHaveBeenCalled() + expect(controller.getFailure('page-1')).toBeNull() + + pageByGuestId.set(guest.id, 'page-1') + controller.onGuestRegistered(guest.id, 'page-1') + + expect(onFailureChanged).toHaveBeenCalledWith( + guest.id, + expect.objectContaining({ challengeId: 'challenge-1', browserPageId: 'page-1' }), + 'https://localhost:3443/app' + ) + }) + + it('rejects stale, expired, destroyed, and mismatched approval attempts', () => { + certificateEvent({ controller, guest }) + + expect(controller.proceed('page-1', 'wrong-challenge')).toEqual({ + ok: false, + reason: 'changed' + }) + now += 5 * 60_000 + expect(controller.proceed('page-1', 'challenge-1')).toEqual({ + ok: false, + reason: 'expired' + }) + + certificateEvent({ controller, guest }) + vi.mocked(guest.isDestroyed).mockReturnValue(true) + expect(controller.proceed('page-1', 'challenge-2')).toEqual({ + ok: false, + reason: 'missing' + }) + expect(guest.loadURL).not.toHaveBeenCalled() + }) + + it('clears pending approval and grants when navigation or guest ownership changes', () => { + certificateEvent({ controller, guest }) + controller.onMainFrameNavigationStarted(guest.id) + expect(controller.getFailure('page-1')).toBeNull() + expect(controller.proceed('page-1', 'challenge-1')).toEqual({ + ok: false, + reason: 'missing' + }) + + certificateEvent({ controller, guest }) + expect(controller.proceed('page-1', 'challenge-2')).toEqual({ ok: true }) + controller.onMainFrameNavigationCommitted(guest.id, 'https://example.com/') + const afterCommit = certificateEvent({ controller, guest }) + expect(afterCommit.callback).toHaveBeenCalledWith(false) + + controller.onGuestRetired(guest.id) + expect(controller.getFailure('page-1')).toBeNull() + }) + + it('answers malformed and unmanaged events exactly once without overriding Electron', () => { + pageByGuestId.delete(guest.id) + const unmanaged = certificateEvent({ controller, guest }) + const malformedUrl = certificateEvent({ controller, guest: otherGuest, url: 'not a URL' }) + const malformedCertificate = certificateEvent({ + controller, + guest: otherGuest, + certificate: { data: 'not a PEM certificate' } as Electron.Certificate + }) + + for (const event of [unmanaged, malformedUrl, malformedCertificate]) { + expect(event.callback).toHaveBeenCalledOnce() + expect(event.callback).toHaveBeenCalledWith(false) + expect(event.preventDefault).not.toHaveBeenCalled() + } + }) +}) diff --git a/src/main/browser/browser-certificate-trust-controller.ts b/src/main/browser/browser-certificate-trust-controller.ts new file mode 100644 index 00000000000..e69d7ad2ca3 --- /dev/null +++ b/src/main/browser/browser-certificate-trust-controller.ts @@ -0,0 +1,300 @@ +import { randomUUID } from 'node:crypto' +import type { BrowserCertificateFailure, BrowserCertificateProceedResult } from '../../shared/types' +import { + isEligibleLocalCertificateHost, + toSecureCertificateEndpoint +} from '../../shared/browser-url' +import { + certificateChallengeIdentityMatches, + CERTIFICATE_CHALLENGE_TTL_MS, + MAX_PENDING_CERTIFICATE_CHALLENGES, + toBrowserCertificateFailure, + type BrowserCertificateTrustControllerDependencies, + type PendingCertificateChallenge +} from './browser-certificate-challenge' +import { + getLeafCertificateSha256, + getSupportedCertificateErrorCode, + normalizeCertificateError, + SUPPORTED_CERTIFICATE_ERROR, + SUPPORTED_CERTIFICATE_ERROR_CODE +} from './browser-certificate-identity' +import { BrowserCertificateRequestGuard } from './browser-certificate-request-guard' + +export type { ManagedBrowserGuestContext } from './browser-certificate-challenge' + +export class BrowserCertificateTrustController { + private readonly pendingByGuestId = new Map() + private readonly navigationSequenceByGuestId = new Map() + private readonly expiryTimerByGuestId = new Map>() + private readonly requestGuard: BrowserCertificateRequestGuard + + constructor(private readonly dependencies: BrowserCertificateTrustControllerDependencies) { + this.requestGuard = new BrowserCertificateRequestGuard({ + onBlockedMainFrame: (blocked) => { + const context = this.dependencies.resolveManagedGuestContext(blocked.webContentsId) + if (context) { + this.recordPendingChallenge({ + ...blocked, + browserPageId: context.browserPageId + }) + } + } + }) + } + + installSessionRequestGuard(session: Electron.Session): void { + this.requestGuard.installSession(session) + } + removeSessionRequestGuard(session: Electron.Session): void { + this.requestGuard.removeSession(session) + } + + handleCertificateError(args: { + event: Pick + webContents: Electron.WebContents + url: string + error: string + certificate: Electron.Certificate + callback: (isTrusted: boolean) => void + isMainFrame: boolean + }): void { + let answered = false + const answer = (trusted: boolean): void => { + if (!answered) { + answered = true + args.callback(trusted) + } + } + try { + const context = this.dependencies.resolveManagedGuestContext(args.webContents.id) + const parsed = new URL(args.url) + const endpoint = toSecureCertificateEndpoint(args.url) + const digest = getLeafCertificateSha256(args.certificate) + const error = normalizeCertificateError(args.error) + if (!context || !endpoint || !digest) { + answer(false) + return + } + const identity = { secureEndpoint: endpoint, leafCertificateSha256: digest, error } + if ( + this.requestGuard.shouldTrustCertificate( + args.webContents.session, + args.webContents.id, + identity + ) + ) { + args.event.preventDefault() + answer(true) + return + } + if ( + args.isMainFrame && + parsed.protocol === 'https:' && + error === SUPPORTED_CERTIFICATE_ERROR && + isEligibleLocalCertificateHost(parsed.hostname) && + this.requestGuard.canOfferCertificate(args.webContents.session, identity) + ) { + this.recordPendingChallenge({ + webContentsId: args.webContents.id, + browserPageId: context.browserPageId, + navigationUrl: args.url, + origin: parsed.origin, + displayHost: parsed.host, + secureEndpoint: endpoint, + leafCertificateSha256: digest, + error + }) + } + answer(false) + } catch { + answer(false) + } + } + + onGuestRegistered(webContentsId: number, browserPageId: string): void { + const pending = this.pendingByGuestId.get(webContentsId) + if (!pending) { + return + } + pending.browserPageId = browserPageId + this.emitFailure(pending) + } + + onGuestRetired(webContentsId: number): void { + this.clearPending(webContentsId, true) + this.requestGuard.revokeGuest(webContentsId) + this.navigationSequenceByGuestId.delete(webContentsId) + } + + onMainFrameNavigationStarted(webContentsId: number): void { + this.navigationSequenceByGuestId.set( + webContentsId, + (this.navigationSequenceByGuestId.get(webContentsId) ?? 0) + 1 + ) + this.clearPending(webContentsId, true) + } + + onMainFrameNavigationCommitted(webContentsId: number, url: string): void { + this.requestGuard.revokeForCommittedNavigation(webContentsId, url) + } + + getFailure(browserPageId: string): BrowserCertificateFailure | null { + this.pruneExpired() + for (const pending of this.pendingByGuestId.values()) { + if (pending.browserPageId === browserPageId) { + return toBrowserCertificateFailure(pending) + } + } + return null + } + + proceed(browserPageId: string, challengeId: string): BrowserCertificateProceedResult { + const webContentsId = this.dependencies.resolveWebContentsIdForPage(browserPageId) + if (webContentsId === null) { + return { ok: false, reason: 'missing' } + } + const pending = this.pendingByGuestId.get(webContentsId) + if (!pending) { + return { ok: false, reason: 'missing' } + } + if (pending.challengeId !== challengeId || pending.browserPageId !== browserPageId) { + return { ok: false, reason: 'changed' } + } + if (pending.expiresAt <= this.now()) { + this.clearPending(webContentsId, true) + return { ok: false, reason: 'expired' } + } + if (pending.errorCode !== SUPPORTED_CERTIFICATE_ERROR_CODE) { + return { ok: false, reason: 'ineligible' } + } + if (pending.navigationSequence !== (this.navigationSequenceByGuestId.get(webContentsId) ?? 0)) { + return { ok: false, reason: 'navigated' } + } + const context = this.dependencies.resolveManagedGuestContext(webContentsId) + const guest = this.dependencies.resolveWebContents(webContentsId) + if (!context || context.browserPageId !== browserPageId || !guest || guest.isDestroyed()) { + return { ok: false, reason: 'missing' } + } + const granted = this.requestGuard.grant(guest.session, { + guestWebContentsId: webContentsId, + secureEndpoint: pending.secureEndpoint, + leafCertificateSha256: pending.leafCertificateSha256, + error: pending.error + }) + if (!granted) { + return { ok: false, reason: 'ineligible' } + } + const navigationUrl = pending.navigationUrl + this.clearPending(webContentsId, true) + void guest.loadURL(navigationUrl).catch(() => {}) + return { ok: true } + } + + private recordPendingChallenge(args: { + webContentsId: number + browserPageId: string | null + navigationUrl: string + origin: string + displayHost: string + secureEndpoint: string + leafCertificateSha256: string + error: string + }): void { + const navigationSequence = this.navigationSequenceByGuestId.get(args.webContentsId) ?? 0 + const existing = this.pendingByGuestId.get(args.webContentsId) + if ( + existing && + certificateChallengeIdentityMatches(existing, { + navigationSequence, + secureEndpoint: args.secureEndpoint, + leafCertificateSha256: args.leafCertificateSha256, + error: args.error + }) + ) { + return + } + this.clearPending(args.webContentsId, true) + const observedAt = this.now() + const pending: PendingCertificateChallenge = { + challengeId: this.dependencies.createChallengeId?.() ?? randomUUID(), + guestWebContentsId: args.webContentsId, + browserPageId: args.browserPageId, + navigationSequence, + navigationUrl: args.navigationUrl, + origin: args.origin, + displayHost: args.displayHost, + secureEndpoint: args.secureEndpoint, + leafCertificateSha256: args.leafCertificateSha256, + errorCode: getSupportedCertificateErrorCode(args.error), + error: args.error, + observedAt, + expiresAt: observedAt + CERTIFICATE_CHALLENGE_TTL_MS + } + this.pendingByGuestId.set(args.webContentsId, pending) + this.scheduleExpiry(pending) + this.enforcePendingBound() + this.emitFailure(pending) + } + + private emitFailure(pending: PendingCertificateChallenge): void { + if (pending.browserPageId) { + this.dependencies.onFailureChanged( + pending.guestWebContentsId, + toBrowserCertificateFailure(pending), + pending.navigationUrl + ) + } + } + + private clearPending(webContentsId: number, notify: boolean): void { + const pending = this.pendingByGuestId.get(webContentsId) + this.pendingByGuestId.delete(webContentsId) + const timer = this.expiryTimerByGuestId.get(webContentsId) + if (timer) { + clearTimeout(timer) + this.expiryTimerByGuestId.delete(webContentsId) + } + if (notify && pending?.browserPageId) { + this.dependencies.onFailureChanged(webContentsId, null) + } + } + + private scheduleExpiry(pending: PendingCertificateChallenge): void { + const timer = setTimeout( + () => { + if ( + this.pendingByGuestId.get(pending.guestWebContentsId)?.challengeId === pending.challengeId + ) { + this.clearPending(pending.guestWebContentsId, true) + } + }, + Math.max(0, pending.expiresAt - this.now()) + ) + timer.unref?.() + this.expiryTimerByGuestId.set(pending.guestWebContentsId, timer) + } + + private pruneExpired(): void { + const now = this.now() + for (const pending of this.pendingByGuestId.values()) { + if (pending.expiresAt <= now) { + this.clearPending(pending.guestWebContentsId, true) + } + } + } + + private enforcePendingBound(): void { + while (this.pendingByGuestId.size > MAX_PENDING_CERTIFICATE_CHALLENGES) { + const oldest = this.pendingByGuestId.keys().next().value as number | undefined + if (oldest === undefined) { + return + } + this.clearPending(oldest, true) + } + } + + private now(): number { + return this.dependencies.now?.() ?? Date.now() + } +} diff --git a/src/main/browser/browser-manager-grab.test.ts b/src/main/browser/browser-manager-grab.test.ts index 0d10e915fa8..43652e6b9db 100644 --- a/src/main/browser/browser-manager-grab.test.ts +++ b/src/main/browser/browser-manager-grab.test.ts @@ -781,7 +781,7 @@ describe('browserManager grab operations', () => { const promise = browserManager.awaitGrabSelection('tab-1', 'op-1', guest) // Find the did-start-navigation handler and trigger it with isMainFrame=true - const navHandler = guestOnMock.mock.calls.find( + const navHandler = guestOnMock.mock.calls.findLast( ([event]) => event === 'did-start-navigation' )?.[1] as ((...args: unknown[]) => void) | undefined @@ -798,7 +798,7 @@ describe('browserManager grab operations', () => { void browserManager.awaitGrabSelection('tab-1', 'op-1', guest) // Trigger did-start-navigation with isMainFrame=false (subframe) - const navHandler = guestOnMock.mock.calls.find( + const navHandler = guestOnMock.mock.calls.findLast( ([event]) => event === 'did-start-navigation' )?.[1] as ((...args: unknown[]) => void) | undefined diff --git a/src/main/browser/browser-manager.test.ts b/src/main/browser/browser-manager.test.ts index 912923fc8f5..6a4d6400e44 100644 --- a/src/main/browser/browser-manager.test.ts +++ b/src/main/browser/browser-manager.test.ts @@ -104,6 +104,7 @@ describe('browserManager', () => { webContentsFromIdMock.mockReset() openPopupWithOriginBarMock.mockReset() browserManager.unregisterAll() + browserManager.setBrowserGuestStateChangedListener(null) browserManager.setDictationShortcutForwardingPredicate(null) browserManager.setSettingsResolver(() => ({})) }) @@ -583,6 +584,45 @@ describe('browserManager', () => { expect(browserManager.getSessionProfileIdForTab('browser-1')).toBe('work') }) + it('tracks offscreen load failures for the owning worktree snapshot', () => { + const stateChanged = vi.fn() + const offscreenGuest = { + id: 605, + isDestroyed: vi.fn(() => false), + getType: vi.fn(() => 'window'), + setBackgroundThrottling: vi.fn(), + setWindowOpenHandler: vi.fn(), + on: vi.fn(), + off: vi.fn(), + getURL: vi.fn(() => 'https://localhost:3443/') + } + webContentsFromIdMock.mockReturnValue(offscreenGuest) + browserManager.setBrowserGuestStateChangedListener(stateChanged) + + browserManager.registerOffscreenGuest({ + browserPageId: 'offscreen-page', + worktreeId: 'remote-worktree', + webContentsId: offscreenGuest.id + }) + const didFailLoad = offscreenGuest.on.mock.calls.find( + ([event]) => event === 'did-fail-load' + )?.[1] as ( + event: unknown, + errorCode: number, + errorDescription: string, + validatedUrl: string, + isMainFrame: boolean + ) => void + didFailLoad(null, -202, 'Certificate authority invalid', 'https://localhost:3443/', true) + + expect(browserManager.getBrowserPageLoadError('offscreen-page')).toEqual({ + code: -202, + description: 'Certificate authority invalid', + validatedUrl: 'https://localhost:3443/' + }) + expect(stateChanged).toHaveBeenCalledWith('remote-worktree') + }) + it('falls back to opening popup URLs externally before a guest is registered', () => { const guest = { id: 105, @@ -1588,6 +1628,163 @@ describe('browserManager', () => { validatedUrl: 'http://localhost:3000/' } }) + expect(browserManager.getBrowserPageLoadError('browser-1')).toEqual({ + code: -105, + description: 'Name not resolved', + validatedUrl: 'http://localhost:3000/' + }) + + const didStartNavigationHandler = guestOnMock.mock.calls.find( + ([event]) => event === 'did-start-navigation' + )?.[1] as + | ((event: unknown, url: string, isInPlace: boolean, isMainFrame: boolean) => void) + | undefined + didStartNavigationHandler?.(null, 'http://localhost:3000/retry', false, true) + expect(browserManager.getBrowserPageLoadError('browser-1')).toBeNull() + }) + + it('drops a queued failure when a replacement navigation starts before registration', () => { + const rendererSendMock = vi.fn() + const guest = { + id: 407, + isDestroyed: vi.fn(() => false), + getType: vi.fn(() => 'webview'), + setBackgroundThrottling: guestSetBackgroundThrottlingMock, + setWindowOpenHandler: guestSetWindowOpenHandlerMock, + on: guestOnMock, + off: guestOffMock, + openDevTools: guestOpenDevToolsMock, + getURL: vi.fn(() => 'https://example.com/') + } + webContentsFromIdMock.mockImplementation((id: number) => + id === guest.id + ? guest + : id === rendererWebContentsId + ? { isDestroyed: vi.fn(() => false), send: rendererSendMock } + : null + ) + + browserManager.attachGuestPolicies(guest as never) + const didFailLoad = guestOnMock.mock.calls.find( + ([event]) => event === 'did-fail-load' + )?.[1] as ( + event: unknown, + errorCode: number, + errorDescription: string, + validatedUrl: string, + isMainFrame: boolean + ) => void + const didStartNavigation = guestOnMock.mock.calls.find( + ([event]) => event === 'did-start-navigation' + )?.[1] as (event: unknown, url: string, isInPlace: boolean, isMainFrame: boolean) => void + + didFailLoad(null, -202, 'Certificate authority invalid', 'https://localhost:3443/', true) + didStartNavigation(null, 'https://example.com/', false, true) + expect( + browserManager.registerGuest({ + browserPageId: 'browser-late-registration', + webContentsId: guest.id, + rendererWebContentsId + }) + ).toBe(true) + + expect(rendererSendMock).not.toHaveBeenCalledWith( + 'browser:guest-load-failed', + expect.anything() + ) + expect(browserManager.getBrowserPageLoadError('browser-late-registration')).toBeNull() + }) + + it('keeps a certificate failure until a real main-frame navigation starts', () => { + const guest = { + id: 405, + isDestroyed: vi.fn(() => false), + getType: vi.fn(() => 'webview'), + setBackgroundThrottling: guestSetBackgroundThrottlingMock, + setWindowOpenHandler: guestSetWindowOpenHandlerMock, + on: guestOnMock, + off: guestOffMock, + openDevTools: guestOpenDevToolsMock, + send: vi.fn(), + getURL: vi.fn(() => 'chrome-error://chromewebdata/') + } + webContentsFromIdMock.mockReturnValue(guest) + + browserManager.attachGuestPolicies(guest as never) + const didStartNavigation = guestOnMock.mock.calls.find( + ([event]) => event === 'did-start-navigation' + )?.[1] as (event: unknown, url: string, isInPlace: boolean, isMainFrame: boolean) => void + const didFailLoad = guestOnMock.mock.calls.find( + ([event]) => event === 'did-fail-load' + )?.[1] as ( + event: unknown, + errorCode: number, + errorDescription: string, + validatedUrl: string, + isMainFrame: boolean + ) => void + + browserManager.registerGuest({ + browserPageId: 'browser-certificate-page', + webContentsId: guest.id, + rendererWebContentsId + }) + didFailLoad(null, -202, 'Certificate authority invalid', 'https://localhost:3443/', true) + didStartNavigation(null, 'chrome-error://chromewebdata/', false, true) + expect(browserManager.getBrowserPageLoadError('browser-certificate-page')?.code).toBe(-202) + + didStartNavigation(null, 'https://localhost:3443/', false, true) + expect(browserManager.getBrowserPageLoadError('browser-certificate-page')).toBeNull() + }) + + it('restores a certificate failure when a retry navigation aborts before committing', () => { + const guest = { + id: 406, + isDestroyed: vi.fn(() => false), + getType: vi.fn(() => 'webview'), + setBackgroundThrottling: guestSetBackgroundThrottlingMock, + setWindowOpenHandler: guestSetWindowOpenHandlerMock, + on: guestOnMock, + off: guestOffMock, + openDevTools: guestOpenDevToolsMock, + send: vi.fn(), + getURL: vi.fn(() => 'chrome-error://chromewebdata/') + } + webContentsFromIdMock.mockReturnValue(guest) + + browserManager.attachGuestPolicies(guest as never) + const didStartNavigation = guestOnMock.mock.calls.find( + ([event]) => event === 'did-start-navigation' + )?.[1] as (event: unknown, url: string, isInPlace: boolean, isMainFrame: boolean) => void + const didFailLoad = guestOnMock.mock.calls.find( + ([event]) => event === 'did-fail-load' + )?.[1] as ( + event: unknown, + errorCode: number, + errorDescription: string, + validatedUrl: string, + isMainFrame: boolean + ) => void + + browserManager.registerGuest({ + browserPageId: 'browser-retry-abort-page', + webContentsId: guest.id, + rendererWebContentsId + }) + didFailLoad(null, -202, 'Certificate authority invalid', 'https://localhost:3443/', true) + // Retry: the new navigation starts (overlay optimistically cleared)... + didStartNavigation(null, 'https://localhost:3443/', false, true) + expect(browserManager.getBrowserPageLoadError('browser-retry-abort-page')).toBeNull() + // ...then aborts (ERR_ABORTED) before committing, so the error is restored. + didFailLoad(null, -3, 'Aborted', 'https://localhost:3443/', true) + expect(browserManager.getBrowserPageLoadError('browser-retry-abort-page')?.code).toBe(-202) + + // A fresh navigation from a non-errored state drops the stash, so a later + // abort cannot resurrect the old error. + didStartNavigation(null, 'https://localhost:3443/', false, true) // stashes -202, clears active + didStartNavigation(null, 'https://example.com/', false, true) // active empty -> drops stash + didFailLoad(null, -3, 'Aborted', 'https://example.com/', true) + expect(browserManager.getBrowserPageLoadError('browser-retry-abort-page')).toBeNull() }) it('queues permission denials and download requests until the guest registers', () => { diff --git a/src/main/browser/browser-manager.ts b/src/main/browser/browser-manager.ts index b9232bcd8d6..60352732efa 100644 --- a/src/main/browser/browser-manager.ts +++ b/src/main/browser/browser-manager.ts @@ -9,7 +9,8 @@ import { ORCA_BROWSER_BLANK_URL } from '../../shared/constants' import { normalizeBrowserNavigationUrl, normalizeExternalBrowserUrl, - redactKagiSessionToken + redactKagiSessionToken, + toSecureCertificateEndpoint } from '../../shared/browser-url' import type { BrowserDownloadFinishedEvent, @@ -52,9 +53,18 @@ import { buildBrowserAnnotationViewportBridgeScript } from '../../shared/browser-annotation-viewport-bridge' import type { KeybindingOverrides } from '../../shared/keybindings' +import type { BrowserCertificateFailure, BrowserLoadError } from '../../shared/types' +import { + BrowserCertificateTrustController, + type ManagedBrowserGuestContext +} from './browser-certificate-trust-controller' const AUTOMATION_VISIBILITY_ACQUIRE_TIMEOUT_MS = 2_000 +function isChromiumInternalErrorUrl(url: string): boolean { + return url.startsWith('chrome-error://') +} + function resolveWithTimeout( promise: Promise, timeoutMs: number, @@ -238,23 +248,47 @@ export class BrowserManager { private readonly annotationViewportBridgeOpsByTabId = new Map>() private readonly worktreeIdByTabId = new Map() private readonly policyAttachedGuestIds = new Set() + private readonly offscreenGuestIds = new Set() private readonly policyCleanupByGuestId = new Map void>() private readonly clickedLinkFrameNameByGuestId = new Map() + private readonly loadErrorsByGuestId = new Map() + // Why: did-start-navigation optimistically hides the overlay, but an aborted + // nav never commits — stash the cleared error so did-fail-load(-3) can restore + // it instead of stranding the user on a blank surface. + private readonly clearedLoadErrorsByGuestId = new Map() + private browserGuestStateChangedListener: ((worktreeId: string) => void) | null = null + private certificateTrustController: BrowserCertificateTrustController | null = null private shouldForwardDictationShortcut: (() => boolean) | null = null private readonly pendingLoadFailuresByGuestId = new Map< number, { code: number; description: string; validatedUrl: string } >() - - setDictationShortcutForwardingPredicate(predicate: (() => boolean) | null): void { - this.shouldForwardDictationShortcut = predicate - } private readonly pendingPermissionEventsByGuestId = new Map() private readonly pendingPopupEventsByGuestId = new Map() private readonly pendingDownloadIdsByGuestId = new Map() private readonly downloadsById = new Map() private readonly grabSessionController = new BrowserGrabSessionController() + setDictationShortcutForwardingPredicate(predicate: (() => boolean) | null): void { + this.shouldForwardDictationShortcut = predicate + } + + setBrowserGuestStateChangedListener(listener: ((worktreeId: string) => void) | null): void { + this.browserGuestStateChangedListener = listener + } + + setCertificateTrustController(controller: BrowserCertificateTrustController): void { + this.certificateTrustController = controller + } + + installCertificateRequestGuard(session: Electron.Session): void { + this.certificateTrustController?.installSessionRequestGuard(session) + } + + removeCertificateRequestGuard(session: Electron.Session): void { + this.certificateTrustController?.removeSessionRequestGuard(session) + } + setSettingsResolver( resolver: () => { keybindings?: KeybindingOverrides @@ -827,18 +861,84 @@ export class BrowserManager { validatedURL: string, isMainFrame: boolean ): void => { - if (!isMainFrame || errorCode === -3) { + if (!isMainFrame) { return } - this.forwardOrQueueGuestLoadFailure(guest.id, { + const browserPageId = this.tabIdByWebContentsId.get(guest.id) + const certificateFailure = browserPageId + ? this.certificateTrustController?.getFailure(browserPageId) + : null + if ( + certificateFailure && + toSecureCertificateEndpoint(validatedURL || guest.getURL()) === + toSecureCertificateEndpoint(certificateFailure.origin) + ) { + // Why: a request-guard cancellation is the transport for the existing + // certificate warning; do not replace it with ERR_ABORTED/blocked copy. + return + } + if (errorCode === -3) { + // Why: an aborted main-frame nav never committed, so restore the error + // did-start-navigation optimistically cleared — otherwise a retry that + // aborts leaves the failed page with no overlay. + const clearedError = this.clearedLoadErrorsByGuestId.get(guest.id) + if (clearedError !== undefined) { + this.clearedLoadErrorsByGuestId.delete(guest.id) + this.loadErrorsByGuestId.set(guest.id, clearedError) + this.forwardOrQueueGuestLoadFailure(guest.id, clearedError) + this.notifyBrowserGuestStateChanged(guest.id) + } + return + } + this.clearedLoadErrorsByGuestId.delete(guest.id) + const loadError = { code: errorCode, description: errorDescription || 'This site could not be reached.', - validatedUrl: validatedURL || guest.getURL() || 'about:blank' - }) + validatedUrl: redactKagiSessionToken(validatedURL || guest.getURL() || 'about:blank') + } + this.loadErrorsByGuestId.set(guest.id, loadError) + this.forwardOrQueueGuestLoadFailure(guest.id, loadError) + this.notifyBrowserGuestStateChanged(guest.id) + } + + const didStartNavigationHandler = ( + _event: Electron.Event, + url: string, + _isInPlace: boolean, + isMainFrame: boolean + ): void => { + if (!isMainFrame || isChromiumInternalErrorUrl(url)) { + return + } + this.certificateTrustController?.onMainFrameNavigationStarted(guest.id) + // Why: a failure queued before renderer registration belongs only to the + // navigation that produced it. A replacement navigation must not replay + // that stale failure when its later dom-ready registers the guest. + this.pendingLoadFailuresByGuestId.delete(guest.id) + const activeError = this.loadErrorsByGuestId.get(guest.id) + if (activeError === undefined) { + // Why: no error to hide; drop any stale stash so a later abort cannot + // resurrect an error from a navigation that already succeeded. + this.clearedLoadErrorsByGuestId.delete(guest.id) + return + } + this.clearedLoadErrorsByGuestId.set(guest.id, activeError) + this.loadErrorsByGuestId.delete(guest.id) + this.notifyBrowserGuestStateChanged(guest.id) + } + + const didNavigateHandler = (_event: Electron.Event, url: string): void => { + // Why: a committed navigation means the optimistic stash from + // did-start-navigation is obsolete — drop it so a later ERR_ABORTED + // cannot restore a failure over the already-committed page. + this.clearedLoadErrorsByGuestId.delete(guest.id) + this.certificateTrustController?.onMainFrameNavigationCommitted(guest.id, url) } guest.on('will-navigate', navigationGuard) guest.on('will-redirect', navigationGuard) + guest.on('did-start-navigation', didStartNavigationHandler) + guest.on('did-navigate', didNavigateHandler) guest.on('did-fail-load', didFailLoadHandler) const handleDestroyed = (): void => { // Why: guests can be destroyed before renderer registration. Without @@ -874,6 +974,8 @@ export class BrowserManager { if (!guest.isDestroyed()) { guest.off('will-navigate', navigationGuard) guest.off('will-redirect', navigationGuard) + guest.off('did-start-navigation', didStartNavigationHandler) + guest.off('did-navigate', didNavigateHandler) guest.off('did-fail-load', didFailLoadHandler) } }) @@ -918,6 +1020,7 @@ export class BrowserManager { private cleanupGuestPolicyAttachment(guestWebContentsId: number): void { const isPrimaryGuest = this.tabIdByWebContentsId.has(guestWebContentsId) + this.certificateTrustController?.onGuestRetired(guestWebContentsId) const policyCleanup = this.policyCleanupByGuestId.get(guestWebContentsId) if (policyCleanup) { policyCleanup() @@ -925,6 +1028,7 @@ export class BrowserManager { } this.policyAttachedGuestIds.delete(guestWebContentsId) this.clickedLinkFrameNameByGuestId.delete(guestWebContentsId) + this.offscreenGuestIds.delete(guestWebContentsId) this.popupOwnerContextByGuestId.delete(guestWebContentsId) // Why: a popup must stop inheriting authorization as soon as its primary // owner is retired, even if Chromium has not destroyed the child yet. @@ -936,6 +1040,8 @@ export class BrowserManager { } } this.pendingLoadFailuresByGuestId.delete(guestWebContentsId) + this.loadErrorsByGuestId.delete(guestWebContentsId) + this.clearedLoadErrorsByGuestId.delete(guestWebContentsId) this.pendingPermissionEventsByGuestId.delete(guestWebContentsId) this.pendingPopupEventsByGuestId.delete(guestWebContentsId) this.cancelPendingDownloadsForGuest(guestWebContentsId) @@ -949,10 +1055,10 @@ export class BrowserManager { sessionProfileId, webContentsId, rendererWebContentsId - }: BrowserGuestRegistration): void { + }: BrowserGuestRegistration): boolean { const browserTabId = browserPageId ?? legacyBrowserTabId if (!browserTabId) { - return + return false } // Why: re-registering the same browser tab can happen when Chromium swaps // or recreates the underlying guest surface. Any active grab is bound to @@ -968,7 +1074,7 @@ export class BrowserManager { const guest = webContents.fromId(webContentsId) if (!guest || guest.isDestroyed()) { - return + return false } // Why: the renderer sends webContentsId, which we must not blindly trust. @@ -976,21 +1082,20 @@ export class BrowserManager { // causing us to overwrite its setWindowOpenHandler or attach unintended // context menus. Only accept genuine webview guest surfaces. if (guest.getType() !== 'webview') { - return + return false } if (!this.policyAttachedGuestIds.has(webContentsId)) { // Why: renderer registration is only the second half of the guest setup. // Main must only trust guests that already passed attach-time policy // installation; otherwise a trusted renderer could point us at some other // arbitrary webview and bypass the intended host-window attach boundary. - return + return false } const previousWebContentsId = this.webContentsIdByTabId.get(browserTabId) if (previousWebContentsId !== undefined && previousWebContentsId !== webContentsId) { this.retireStaleGuestWebContents(previousWebContentsId) } - this.webContentsIdByTabId.set(browserTabId, webContentsId) this.tabIdByWebContentsId.set(webContentsId, browserTabId) if (workspaceId) { @@ -1001,6 +1106,7 @@ export class BrowserManager { if (worktreeId) { this.worktreeIdByTabId.set(browserTabId, worktreeId) } + this.certificateTrustController?.onGuestRegistered(webContentsId, browserTabId) this.setupContextMenu(browserTabId, guest) this.setupGrabShortcut(browserTabId, guest) @@ -1010,6 +1116,7 @@ export class BrowserManager { this.flushPendingPermissionEvents(browserTabId, webContentsId) this.flushPendingPopupEvents(browserTabId, webContentsId) this.flushPendingDownloadRequests(browserTabId, webContentsId) + return true } unregisterGuest(browserTabId: string): void { @@ -1088,6 +1195,10 @@ export class BrowserManager { if (!guest || guest.isDestroyed()) { return } + // Why: offscreen pages have no renderer webview listeners, so main owns + // their load-failure lifecycle for remote browser chrome. + this.offscreenGuestIds.add(webContentsId) + this.attachGuestPolicies(guest) const previousWebContentsId = this.webContentsIdByTabId.get(browserPageId) if (previousWebContentsId !== undefined && previousWebContentsId !== webContentsId) { this.retireStaleGuestWebContents(previousWebContentsId) @@ -1098,6 +1209,7 @@ export class BrowserManager { if (worktreeId) { this.worktreeIdByTabId.set(browserPageId, worktreeId) } + this.certificateTrustController?.onGuestRegistered(webContentsId, browserPageId) } unregisterAll(): void { @@ -1111,6 +1223,7 @@ export class BrowserManager { this.unregisterGuest(browserTabId) } this.policyAttachedGuestIds.clear() + this.offscreenGuestIds.clear() // Why: unregisterGuest only cleans up guests that were registered (have an // entry in webContentsIdByTabId). Guests that went through // attachGuestPolicies but were never registered still have cleanup closures @@ -1125,6 +1238,8 @@ export class BrowserManager { this.worktreeIdByTabId.clear() this.sessionProfileIdByPageId.clear() this.pendingLoadFailuresByGuestId.clear() + this.loadErrorsByGuestId.clear() + this.clearedLoadErrorsByGuestId.clear() this.pendingPermissionEventsByGuestId.clear() this.pendingPopupEventsByGuestId.clear() this.pendingDownloadIdsByGuestId.clear() @@ -1148,6 +1263,86 @@ export class BrowserManager { return this.sessionProfileIdByPageId.get(browserTabId) ?? null } + getBrowserPageLoadError(browserPageId: string): BrowserLoadError | null { + const webContentsId = this.webContentsIdByTabId.get(browserPageId) + return webContentsId === undefined + ? null + : (this.loadErrorsByGuestId.get(webContentsId) ?? null) + } + + getBrowserPageCertificateFailure(browserPageId: string): BrowserCertificateFailure | null { + return this.certificateTrustController?.getFailure(browserPageId) ?? null + } + + getManagedBrowserGuestContext(webContentsId: number): ManagedBrowserGuestContext | null { + if (this.popupOwnerContextByGuestId.has(webContentsId)) { + return null + } + const browserPageId = this.tabIdByWebContentsId.get(webContentsId) ?? null + const offscreen = this.offscreenGuestIds.has(webContentsId) + if (!offscreen && !this.policyAttachedGuestIds.has(webContentsId)) { + return null + } + if (!offscreen) { + const guest = webContents.fromId(webContentsId) + if (!guest || guest.isDestroyed() || guest.getType() !== 'webview') { + return null + } + } + return { + browserPageId, + worktreeId: browserPageId ? (this.worktreeIdByTabId.get(browserPageId) ?? null) : null, + sessionProfileId: browserPageId + ? (this.sessionProfileIdByPageId.get(browserPageId) ?? null) + : null, + owner: offscreen ? 'offscreen' : 'desktop-webview' + } + } + + notifyCertificateFailureChanged( + webContentsId: number, + failure: BrowserCertificateFailure | null, + navigationUrl?: string + ): void { + if (failure && navigationUrl) { + const loadError = { + code: failure.errorCode ?? -1, + description: failure.error, + validatedUrl: redactKagiSessionToken(navigationUrl) + } + this.loadErrorsByGuestId.set(webContentsId, loadError) + this.forwardOrQueueGuestLoadFailure(webContentsId, loadError) + } + const browserPageId = this.tabIdByWebContentsId.get(webContentsId) + if (!browserPageId) { + return + } + if (this.offscreenGuestIds.has(webContentsId)) { + this.notifyBrowserGuestStateChanged(webContentsId) + return + } + const renderer = this.resolveRendererForBrowserTab(browserPageId) + renderer?.send('browser:certificate-failure-changed', { browserPageId, failure }) + } + + private notifyBrowserGuestStateChanged(webContentsId: number): void { + if (!this.offscreenGuestIds.has(webContentsId)) { + return + } + const browserPageId = this.tabIdByWebContentsId.get(webContentsId) + const worktreeId = browserPageId ? this.worktreeIdByTabId.get(browserPageId) : null + if (worktreeId) { + // Why: this runs inside an Electron guest event dispatch; the listener + // synchronously reconciles mobile-session tabs, and an escaping throw would + // become a fatal uncaught exception (no catch-all main-process guard). + try { + this.browserGuestStateChangedListener?.(worktreeId) + } catch (error) { + console.error('[browser-manager] browserGuestStateChanged listener failed', error) + } + } + } + notifyPermissionDenied(args: { guestWebContentsId: number permission: string @@ -2033,3 +2228,13 @@ export class BrowserManager { } export const browserManager = new BrowserManager() +export const browserCertificateTrustController = new BrowserCertificateTrustController({ + resolveManagedGuestContext: (webContentsId) => + browserManager.getManagedBrowserGuestContext(webContentsId), + resolveWebContentsIdForPage: (browserPageId) => + browserManager.getGuestWebContentsId(browserPageId), + resolveWebContents: (webContentsId) => webContents.fromId(webContentsId) ?? null, + onFailureChanged: (webContentsId, failure, navigationUrl) => + browserManager.notifyCertificateFailureChanged(webContentsId, failure, navigationUrl) +}) +browserManager.setCertificateTrustController(browserCertificateTrustController) diff --git a/src/main/browser/browser-session-registry.persistence.test.ts b/src/main/browser/browser-session-registry.persistence.test.ts index 583afff0815..174b5aa7715 100644 --- a/src/main/browser/browser-session-registry.persistence.test.ts +++ b/src/main/browser/browser-session-registry.persistence.test.ts @@ -109,7 +109,9 @@ function installModuleMocks( vi.doMock('./browser-manager', () => ({ browserManager: { notifyPermissionDenied: browserManagerNotifyPermissionDeniedMock, - handleGuestWillDownload: browserManagerHandleGuestWillDownloadMock + handleGuestWillDownload: browserManagerHandleGuestWillDownloadMock, + installCertificateRequestGuard: vi.fn(), + removeCertificateRequestGuard: vi.fn() } })) vi.doMock('./browser-media-access', () => ({ diff --git a/src/main/browser/browser-session-registry.test.ts b/src/main/browser/browser-session-registry.test.ts index 8d81590ae5c..f116e08c0da 100644 --- a/src/main/browser/browser-session-registry.test.ts +++ b/src/main/browser/browser-session-registry.test.ts @@ -21,7 +21,9 @@ vi.mock('electron', () => ({ vi.mock('./browser-manager', () => ({ browserManager: { notifyPermissionDenied: vi.fn(), - handleGuestWillDownload: vi.fn() + handleGuestWillDownload: vi.fn(), + installCertificateRequestGuard: vi.fn(), + removeCertificateRequestGuard: vi.fn() } })) diff --git a/src/main/browser/browser-session-registry.ts b/src/main/browser/browser-session-registry.ts index 3603de11568..a70aff4e6b2 100644 --- a/src/main/browser/browser-session-registry.ts +++ b/src/main/browser/browser-session-registry.ts @@ -551,6 +551,7 @@ class BrowserSessionRegistry { } const sess = session.fromPartition(partition) + browserManager.installCertificateRequestGuard(sess) if (typeof sess.getUserAgent === 'function') { const cleanUA = cleanElectronUserAgent(sess.getUserAgent()) sess.setUserAgent(cleanUA) @@ -622,6 +623,7 @@ class BrowserSessionRegistry { // Electron Session object survives; clear policy callbacks and listener // bookkeeping so removed profiles do not leave retained closures behind. this.configuredPartitions.delete(partition) + browserManager.removeCertificateRequestGuard(sess) sess.removeListener('will-download', this.handleWillDownload) clearBrowserWebAuthnAccessHandlers(sess) sess.setPermissionRequestHandler(null) diff --git a/src/main/index.ts b/src/main/index.ts index b120f62c8a5..cebff02a96f 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -163,7 +163,7 @@ import { } from './ipc/pty' import { AgentBrowserBridge } from './browser/agent-browser-bridge' import { EmulatorBridge } from './emulator/emulator-bridge' -import { browserManager } from './browser/browser-manager' +import { browserCertificateTrustController, browserManager } from './browser/browser-manager' import { OffscreenBrowserBackend } from './browser/offscreen-browser-backend' import { initializeBrowserSessionsForApp } from './browser/browser-session-startup' import { setUnreadDockBadgeCount } from './dock/unread-badge' @@ -1710,6 +1710,22 @@ function shouldSuppressCodexAutoApprovalSyntheticTitleFromHook(args: { app.whenReady().then(async () => { logStartupMilestone('app-ready') + // Why: certificate decisions must be installed before either desktop + // webviews or headless browser windows can issue their first TLS request. + app.on( + 'certificate-error', + (event, webContents, url, error, certificate, callback, isMainFrame) => { + browserCertificateTrustController.handleCertificateError({ + event, + webContents, + url, + error, + certificate, + callback, + isMainFrame + }) + } + ) electronApp.setAppUserModelId(devInstanceIdentity.appUserModelId) app.setName(devInstanceIdentity.name) @@ -1919,6 +1935,9 @@ app.whenReady().then(async () => { isAgentStatusHooksEnabled(store?.getSettings()) ? agentHookServer.buildPtyEnv() : {} }) runtime = runtimeService + browserManager.setBrowserGuestStateChangedListener((worktreeId) => { + runtimeService.notifyMobileSessionTabsChanged(worktreeId) + }) automations = new AutomationService(store, { claudeUsage, codexUsage, @@ -2401,6 +2420,7 @@ app.on('will-quit', (e) => { // Why: headless offscreen browser windows are main-process owned; tear them // down explicitly on quit alongside the other browser/session shutdowns. runtime?.getOffscreenBrowserBackend()?.destroyAll?.() + browserManager.setBrowserGuestStateChangedListener(null) const emulatorShutdown = runtime?.getEmulatorBridge()?.destroyAllSessions() ?? Promise.resolve() killAllPty() const watcherShutdown = shutdownWatchersOnce() diff --git a/src/main/ipc/browser.test.ts b/src/main/ipc/browser.test.ts index 042140cd38b..7651d130651 100644 --- a/src/main/ipc/browser.test.ts +++ b/src/main/ipc/browser.test.ts @@ -11,6 +11,7 @@ const { openDevToolsMock, setAnnotationViewportBridgeMock, cancelDownloadMock, + proceedCertificateMock, browserWindowFromWebContentsMock, webContentsFromIdMock } = vi.hoisted(() => ({ @@ -24,6 +25,7 @@ const { openDevToolsMock: vi.fn().mockResolvedValue(true), setAnnotationViewportBridgeMock: vi.fn().mockResolvedValue(true), cancelDownloadMock: vi.fn(), + proceedCertificateMock: vi.fn(), browserWindowFromWebContentsMock: vi.fn(), webContentsFromIdMock: vi.fn() })) @@ -42,6 +44,9 @@ vi.mock('electron', () => ({ })) vi.mock('../browser/browser-manager', () => ({ + browserCertificateTrustController: { + proceed: proceedCertificateMock + }, browserManager: { registerGuest: registerGuestMock, unregisterGuest: unregisterGuestMock, @@ -68,6 +73,7 @@ describe('registerBrowserHandlers', () => { removeHandlerMock.mockReset() handleMock.mockReset() registerGuestMock.mockReset() + registerGuestMock.mockReturnValue(true) unregisterGuestMock.mockReset() getGuestWebContentsIdMock.mockReset() getWebContentsIdByTabIdMock.mockReset() @@ -76,6 +82,8 @@ describe('registerBrowserHandlers', () => { openDevToolsMock.mockReset() setAnnotationViewportBridgeMock.mockReset() cancelDownloadMock.mockReset() + proceedCertificateMock.mockReset() + proceedCertificateMock.mockReturnValue({ ok: true }) browserWindowFromWebContentsMock.mockReset() webContentsFromIdMock.mockReset() webContentsFromIdMock.mockReturnValue({ isDestroyed: () => false }) @@ -110,6 +118,41 @@ describe('registerBrowserHandlers', () => { expect(registerGuestMock).not.toHaveBeenCalled() }) + it('does not resolve registration waiters when BrowserManager rejects the guest', async () => { + vi.useFakeTimers() + try { + registerGuestMock.mockReturnValue(false) + const settled = Promise.allSettled([waitForTabRegistration('page-1', 1000)]) + registerBrowserHandlers() + const registerHandler = handleMock.mock.calls.find( + ([channel]) => channel === 'browser:registerGuest' + )?.[1] as (event: { sender: Electron.WebContents }, args: object) => boolean + + const result = registerHandler( + { + sender: { + id: 91, + isDestroyed: () => false, + getType: () => 'window', + getURL: () => 'file:///renderer/index.html' + } as Electron.WebContents + }, + { + browserPageId: 'page-1', + workspaceId: 'workspace-1', + worktreeId: 'worktree-1', + webContentsId: 123 + } + ) + + expect(result).toBe(false) + await vi.advanceTimersByTimeAsync(1001) + expect(await settled).toEqual([{ status: 'rejected', reason: expect.any(Error) }]) + } finally { + vi.useRealTimers() + } + }) + it('authorizes browser download cancellation through the owning renderer', () => { cancelDownloadMock.mockReturnValue(true) registerBrowserHandlers() @@ -157,6 +200,60 @@ describe('registerBrowserHandlers', () => { expect(cancelDownloadMock).not.toHaveBeenCalled() }) + it('allows only the trusted renderer to approve an exact certificate challenge', () => { + registerBrowserHandlers() + const proceedHandler = handleMock.mock.calls.find( + ([channel]) => channel === 'browser:proceedCertificate' + )?.[1] as (event: { sender: Electron.WebContents }, args: unknown) => unknown + const trustedSender = { + id: 91, + isDestroyed: () => false, + getType: () => 'window', + getURL: () => 'file:///renderer/index.html' + } as Electron.WebContents + + expect( + proceedHandler( + { sender: trustedSender }, + { browserPageId: 'page-1', challengeId: 'challenge-1' } + ) + ).toEqual({ ok: true }) + expect(proceedCertificateMock).toHaveBeenCalledWith('page-1', 'challenge-1') + + proceedCertificateMock.mockClear() + const untrustedSender = { + id: 92, + isDestroyed: () => false, + getType: () => 'webview', + getURL: () => 'https://localhost:3443/' + } as Electron.WebContents + expect( + proceedHandler( + { sender: untrustedSender }, + { browserPageId: 'page-1', challengeId: 'challenge-1' } + ) + ).toEqual({ ok: false, reason: 'missing' }) + expect(proceedCertificateMock).not.toHaveBeenCalled() + }) + + it('rejects malformed certificate approval IPC arguments', () => { + registerBrowserHandlers() + const proceedHandler = handleMock.mock.calls.find( + ([channel]) => channel === 'browser:proceedCertificate' + )?.[1] as (event: { sender: Electron.WebContents }, args: unknown) => unknown + const sender = { + id: 91, + isDestroyed: () => false, + getType: () => 'window', + getURL: () => 'file:///renderer/index.html' + } as Electron.WebContents + + for (const args of [null, {}, { browserPageId: 1, challengeId: 'challenge-1' }]) { + expect(proceedHandler({ sender }, args)).toEqual({ ok: false, reason: 'missing' }) + } + expect(proceedCertificateMock).not.toHaveBeenCalled() + }) + it('updates the bridge active tab for the owning worktree', async () => { const onTabChangedMock = vi.fn() getGuestWebContentsIdMock.mockReturnValue(4242) diff --git a/src/main/ipc/browser.ts b/src/main/ipc/browser.ts index 1bc405c9d0a..f5c1c8fb268 100644 --- a/src/main/ipc/browser.ts +++ b/src/main/ipc/browser.ts @@ -1,7 +1,7 @@ /* eslint-disable max-lines -- Why: browser IPC handlers must be registered together so the trust boundary (isTrustedBrowserRenderer) and handler teardown stay consistent. */ import { BrowserWindow, ipcMain, webContents } from 'electron' -import { browserManager } from '../browser/browser-manager' +import { browserCertificateTrustController, browserManager } from '../browser/browser-manager' import type { AgentBrowserBridge } from '../browser/agent-browser-bridge' import { browserSessionRegistry } from '../browser/browser-session-registry' import { @@ -24,6 +24,7 @@ import type { } from '../../shared/browser-grab-types' import type { BrowserCookieImportResult, + BrowserCertificateProceedResult, BrowserSessionProfile, BrowserSessionProfileScope, BrowserViewportOverride @@ -180,6 +181,7 @@ export function registerBrowserHandlers(): void { ipcMain.removeHandler('browser:captureSelectionScreenshot') ipcMain.removeHandler('browser:extractHoverPayload') ipcMain.removeHandler('browser:activeTabChanged') + ipcMain.removeHandler('browser:proceedCertificate') ipcMain.handle( 'browser:registerGuest', @@ -201,10 +203,13 @@ export function registerBrowserHandlers(): void { // with a new webContentsId. The bridge must destroy the old session's // proxy (its webContents is gone) and let the next command recreate it. const previousWcId = browserManager.getGuestWebContentsId(args.browserPageId) - browserManager.registerGuest({ + const registered = browserManager.registerGuest({ ...args, rendererWebContentsId: event.sender.id }) + if (!registered) { + return false + } if (agentBrowserBridgeRef && previousWcId !== null && previousWcId !== args.webContentsId) { agentBrowserBridgeRef.onProcessSwap(args.browserPageId, args.webContentsId, previousWcId) } @@ -235,6 +240,23 @@ export function registerBrowserHandlers(): void { return true }) + ipcMain.handle( + 'browser:proceedCertificate', + ( + event, + args: { browserPageId?: unknown; challengeId?: unknown } + ): BrowserCertificateProceedResult => { + if ( + !isTrustedBrowserRenderer(event.sender) || + typeof args?.browserPageId !== 'string' || + typeof args.challengeId !== 'string' + ) { + return { ok: false, reason: 'missing' } + } + return browserCertificateTrustController.proceed(args.browserPageId, args.challengeId) + } + ) + // Why: keeps the bridge's active tab in sync with the renderer's UI state. // Without this, a user switching tabs in the UI would leave the agent operating // on the previous tab, which is confusing. diff --git a/src/main/runtime/orca-runtime-browser.ts b/src/main/runtime/orca-runtime-browser.ts index 053f614663c..c7da17da95d 100644 --- a/src/main/runtime/orca-runtime-browser.ts +++ b/src/main/runtime/orca-runtime-browser.ts @@ -49,9 +49,10 @@ import type { BrowserViewportResult, BrowserWaitResult } from '../../shared/runtime-types' +import type { BrowserCertificateProceedResult } from '../../shared/types' import type { AgentBrowserBridge } from '../browser/agent-browser-bridge' import type { BrowserBackend } from '../browser/browser-backend' -import { browserManager } from '../browser/browser-manager' +import { browserCertificateTrustController, browserManager } from '../browser/browser-manager' import { BrowserError } from '../browser/cdp-bridge' import { startBrowserScreencast, @@ -633,6 +634,16 @@ export class RuntimeBrowserCommands { } } + async browserProceedCertificate( + params: { challengeId: string } & BrowserCommandTargetParams + ): Promise { + const target = await this.resolveBrowserCommandTarget(params) + if (!target.browserPageId) { + return { ok: false, reason: 'missing' } + } + return browserCertificateTrustController.proceed(target.browserPageId, params.challengeId) + } + async browserTabShow(params: { page: string; worktree?: string }): Promise { const target = await this.resolveBrowserCommandTarget(params) return { tab: this.describeBrowserTab(params.page, target.worktreeId) } diff --git a/src/main/runtime/orca-runtime.test.ts b/src/main/runtime/orca-runtime.test.ts index ba20f682015..055d1c2ac48 100644 --- a/src/main/runtime/orca-runtime.test.ts +++ b/src/main/runtime/orca-runtime.test.ts @@ -1538,6 +1538,123 @@ describe('OrcaRuntimeService', () => { expect(capabilities).toContain('browser.screencast.v1') // ...and the headless marker tells clients not to fall back to a local tab. expect(capabilities).toContain('browser.headless.v1') + expect(capabilities).toContain('browser.certificate-trust.v1') + }) + + it('surfaces live offscreen load failures in headless browser snapshots', () => { + const runtime = createRuntime() + runtime.setOffscreenBrowserBackend({ createTab: vi.fn(), closeTab: vi.fn() }) + runtime.setAgentBrowserBridge({ + tabList: vi.fn(() => ({ + tabs: [ + { + browserPageId: 'page-certificate-error', + index: 0, + url: 'https://localhost:3443/', + title: 'Local HTTPS', + active: true, + loadError: { + code: -202, + description: 'ERR_CERT_AUTHORITY_INVALID', + validatedUrl: 'https://localhost:3443/' + }, + certificateFailure: { + challengeId: 'challenge-1', + browserPageId: 'page-certificate-error', + errorCode: -202, + error: 'ERR_CERT_AUTHORITY_INVALID', + origin: 'https://localhost:3443', + displayHost: 'localhost:3443', + canProceed: true, + observedAt: 123 + } + } + ] + })) + } as never) + const browserTabs = runtime['buildHeadlessMobileSessionBrowserTabs'](TEST_WORKTREE_ID) + expect(browserTabs).toContainEqual( + expect.objectContaining({ + type: 'browser', + browserPageId: 'page-certificate-error', + loadError: { + code: -202, + description: 'ERR_CERT_AUTHORITY_INVALID', + validatedUrl: 'https://localhost:3443/' + }, + certificateFailure: { + challengeId: 'challenge-1', + browserPageId: 'page-certificate-error', + errorCode: -202, + error: 'ERR_CERT_AUTHORITY_INVALID', + origin: 'https://localhost:3443', + displayHost: 'localhost:3443', + canProceed: true, + observedAt: 123 + } + }) + ) + }) + + it('detects headless browser-tab changes by field, treating absent and null loadError alike', () => { + const runtime = createRuntime() + const base = { + type: 'browser' as const, + id: 'page-1', + title: 'Local', + browserWorkspaceId: 'page-1', + browserPageId: 'page-1', + url: 'https://localhost:3443/', + loading: false, + canGoBack: false, + canGoForward: false, + isActive: true + } + const err = { + code: -202, + description: 'ERR_CERT_AUTHORITY_INVALID', + validatedUrl: 'https://localhost:3443/' + } + const certificateFailure = { + challengeId: 'challenge-1', + browserPageId: 'page-1', + errorCode: -202, + error: 'ERR_CERT_AUTHORITY_INVALID', + origin: 'https://localhost:3443', + displayHost: 'localhost:3443', + canProceed: true, + observedAt: 123 + } + const unchanged = (a: unknown[], b: unknown[]): boolean => + runtime['headlessBrowserTabsUnchanged'](a as never, b as never) + + // Absent vs explicit null loadError are equivalent (the JSON.stringify trap). + expect(unchanged([{ ...base }], [{ ...base, loadError: null }])).toBe(true) + expect(unchanged([{ ...base, loadError: err }], [{ ...base, loadError: { ...err } }])).toBe( + true + ) + // A load-error-only change (identical ids/order) must not be missed. + expect(unchanged([{ ...base }], [{ ...base, loadError: err }])).toBe(false) + expect( + unchanged([{ ...base, loadError: err }], [{ ...base, loadError: { ...err, code: -200 } }]) + ).toBe(false) + expect( + unchanged( + [{ ...base, certificateFailure }], + [{ ...base, certificateFailure: { ...certificateFailure } }] + ) + ).toBe(true) + expect(unchanged([{ ...base }], [{ ...base, certificateFailure }])).toBe(false) + expect( + unchanged( + [{ ...base, certificateFailure }], + [{ ...base, certificateFailure: { ...certificateFailure, challengeId: 'challenge-2' } }] + ) + ).toBe(false) + // Scalar and length changes are detected. + expect(unchanged([{ ...base }], [{ ...base, title: 'Changed' }])).toBe(false) + expect(unchanged([{ ...base }], [{ ...base, isActive: false }])).toBe(false) + expect(unchanged([{ ...base }], [{ ...base }, { ...base, id: 'page-2' }])).toBe(false) }) it('does not advertise headless browser capability when a renderer window exists', () => { @@ -1547,6 +1664,16 @@ describe('OrcaRuntimeService', () => { runtime.setOffscreenBrowserBackend({ createTab: vi.fn(), closeTab: vi.fn() }) expect(runtime.getStatus().capabilities).not.toContain('browser.headless.v1') + // Desktop webviews still host certificate trust, so the proceed capability + // remains advertised for remote clients controlling those pages. + expect(runtime.getStatus().capabilities).toContain('browser.certificate-trust.v1') + }) + + it('does not advertise certificate trust when no browser backend is available', () => { + const runtime = createRuntime() + + expect(runtime.getStatus().capabilities).not.toContain('browser.certificate-trust.v1') + expect(runtime.getStatus().capabilities).not.toContain('browser.screencast.v1') }) it('closes a worktree’s offscreen browser pages when its metadata is removed (leak fix)', () => { diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index fdf3657e08e..6e1bce48a5a 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -264,6 +264,7 @@ import { } from '../../shared/worktree-ownership' import { BROWSER_HEADLESS_RUNTIME_CAPABILITY, + BROWSER_CERTIFICATE_TRUST_RUNTIME_CAPABILITY, MIN_COMPATIBLE_RUNTIME_CLIENT_VERSION, RUNTIME_CAPABILITIES, RUNTIME_PROTOCOL_VERSION, @@ -3061,6 +3062,12 @@ export class OrcaRuntimeService { if (hasOffscreen) { capabilities.push(BROWSER_HEADLESS_RUNTIME_CAPABILITY) } + // Why: certificate proceed is owned by the browser-hosting process for both + // desktop webviews and offscreen pages. Advertise whenever either backend + // can host a page so remote clients can surface Proceed Anyway (Unsafe). + if (canBrowse) { + capabilities.push(BROWSER_CERTIFICATE_TRUST_RUNTIME_CAPABILITY) + } return { runtimeId: this.runtimeId, rendererGraphEpoch: this.rendererGraphEpoch, @@ -3430,13 +3437,16 @@ export class OrcaRuntimeService { allowAttachedWindow?: boolean onlyServeOwnedTerminals?: boolean } = {} - ): void { + ): Set { + // Why: report which worktrees were reconciled in place so callers don't + // reconcile them a second time (see notifyMobileSessionTabsChanged). + const reconciledWorktreeIds = new Set() if (this.getAvailableAuthoritativeWindow() && options.allowAttachedWindow !== true) { - return + return reconciledWorktreeIds } const session = this.store?.getWorkspaceSession?.() if (!session) { - return + return reconciledWorktreeIds } const entries = worktreeId !== undefined @@ -3455,6 +3465,7 @@ export class OrcaRuntimeService { // Reconcile just the browser tabs against the live bridge instead of // leaving a stale snapshot that omits a freshly-opened browser tab. this.reconcileHeadlessMobileSessionBrowserTabs(entryWorktreeId, existing) + reconciledWorktreeIds.add(entryWorktreeId) continue } const terminalTabs = this.buildHeadlessMobileSessionTerminalTabs( @@ -3554,6 +3565,7 @@ export class OrcaRuntimeService { tabs: mergedTabs }) } + return reconciledWorktreeIds } // Why: keep an existing snapshot's browser tabs in sync with the live bridge @@ -3568,13 +3580,11 @@ export class OrcaRuntimeService { } const liveBrowserTabs = this.buildHeadlessMobileSessionBrowserTabs(worktreeId) const liveIds = liveBrowserTabs.map((tab) => tab.id) - const existingBrowserIds = existing.tabs - .filter((tab): tab is RuntimeMobileSessionBrowserTab => tab.type === 'browser') - .map((tab) => tab.id) - const unchanged = - liveIds.length === existingBrowserIds.length && - liveIds.every((id, index) => existingBrowserIds[index] === id) - if (unchanged) { + const existingBrowserTabs = existing.tabs.filter( + (tab): tab is RuntimeMobileSessionBrowserTab => tab.type === 'browser' + ) + const existingBrowserIds = existingBrowserTabs.map((tab) => tab.id) + if (this.headlessBrowserTabsUnchanged(liveBrowserTabs, existingBrowserTabs)) { return } const nonBrowserTabs = existing.tabs.filter((tab) => tab.type !== 'browser') @@ -4051,6 +4061,8 @@ export class OrcaRuntimeService { loading: false, canGoBack: false, canGoForward: false, + loadError: tab.loadError ?? undefined, + certificateFailure: tab.certificateFailure ?? undefined, ...(persistedProps ? { color: persistedProps.color } : {}), ...(persistedProps ? { isPinned: persistedProps.isPinned === true } : {}), isActive: tab.active === true @@ -4058,6 +4070,75 @@ export class OrcaRuntimeService { }) } + // Why: change detection for headless browser tabs. Compares the fields that + // actually vary (a JSON.stringify equality was order-sensitive and silently + // dropped `undefined` keys, so it only worked while both sides shared one + // construction path). + private headlessBrowserTabsUnchanged( + live: RuntimeMobileSessionBrowserTab[], + existing: RuntimeMobileSessionBrowserTab[] + ): boolean { + if (live.length !== existing.length) { + return false + } + return live.every((tab, index) => { + const prev = existing[index] + return ( + tab.id === prev.id && + tab.title === prev.title && + tab.url === prev.url && + tab.isActive === prev.isActive && + (tab.isPinned ?? false) === (prev.isPinned ?? false) && + (tab.color ?? null) === (prev.color ?? null) && + this.browserLoadErrorsEqual(tab.loadError, prev.loadError) && + this.browserCertificateFailuresEqual(tab.certificateFailure, prev.certificateFailure) + ) + }) + } + + private browserLoadErrorsEqual( + a: RuntimeMobileSessionBrowserTab['loadError'], + b: RuntimeMobileSessionBrowserTab['loadError'] + ): boolean { + const left = a ?? null + const right = b ?? null + if (left === right) { + return true + } + if (!left || !right) { + return false + } + return ( + left.code === right.code && + left.description === right.description && + left.validatedUrl === right.validatedUrl + ) + } + + private browserCertificateFailuresEqual( + a: RuntimeMobileSessionBrowserTab['certificateFailure'], + b: RuntimeMobileSessionBrowserTab['certificateFailure'] + ): boolean { + const left = a ?? null + const right = b ?? null + if (left === right) { + return true + } + if (!left || !right) { + return false + } + return ( + left.challengeId === right.challengeId && + left.browserPageId === right.browserPageId && + left.errorCode === right.errorCode && + left.error === right.error && + left.origin === right.origin && + left.displayHost === right.displayHost && + left.canProceed === right.canProceed && + left.observedAt === right.observedAt + ) + } + private getPersistedUnifiedSessionTabProps( worktreeId: string, tabId: string @@ -21565,6 +21646,17 @@ export class OrcaRuntimeService { this.notifyMobileSessionTabSnapshots() return } + if (this.offscreenBrowserBackend) { + const reconciled = this.hydrateHeadlessMobileSessionTabsFromWorkspaceSession(worktreeId) + // Why: hydrate already reconciles an existing snapshot in place; only + // reconcile here when it didn't (fresh build or an early-returned hydrate). + if (!reconciled.has(worktreeId)) { + const existing = this.mobileSessionTabsByWorktree.get(worktreeId) + if (existing) { + this.reconcileHeadlessMobileSessionBrowserTabs(worktreeId, existing) + } + } + } // Why: structural changes (tab add/remove/activate) must propagate promptly, // so cancel any pending coalesced title/status notify — this immediate emit // already reflects the latest snapshot and supersedes it. @@ -25478,6 +25570,8 @@ export class OrcaRuntimeService { browserTabList: RuntimeBrowserCommands['browserTabList'] = this.browserCommands.browserTabList.bind(this.browserCommands) + browserProceedCertificate: RuntimeBrowserCommands['browserProceedCertificate'] = + this.browserCommands.browserProceedCertificate.bind(this.browserCommands) browserTabShow: RuntimeBrowserCommands['browserTabShow'] = this.browserCommands.browserTabShow.bind(this.browserCommands) diff --git a/src/main/runtime/rpc/methods/browser-core.ts b/src/main/runtime/rpc/methods/browser-core.ts index fc1b1277fce..41484d20768 100644 --- a/src/main/runtime/rpc/methods/browser-core.ts +++ b/src/main/runtime/rpc/methods/browser-core.ts @@ -1,5 +1,5 @@ import { defineMethod, type RpcMethod } from '../core' -import { BrowserTarget } from '../schemas' +import { BrowserTarget, requiredString } from '../schemas' import { Check, Drag, @@ -34,6 +34,10 @@ import { } from './browser-schemas' import { BROWSER_TEXT_METHODS } from './browser-text-rpc-methods' +const CertificateProceed = BrowserTarget.extend({ + challengeId: requiredString('Missing required challengeId') +}) + export const BROWSER_CORE_METHODS: RpcMethod[] = [ defineMethod({ name: 'browser.snapshot', @@ -50,6 +54,11 @@ export const BROWSER_CORE_METHODS: RpcMethod[] = [ params: Goto, handler: async (params, { runtime }) => runtime.browserGoto(params) }), + defineMethod({ + name: 'browser.certificate.proceed', + params: CertificateProceed, + handler: async (params, { runtime }) => runtime.browserProceedCertificate(params) + }), ...BROWSER_TEXT_METHODS, defineMethod({ name: 'browser.select', diff --git a/src/main/runtime/rpc/methods/browser-schemas.ts b/src/main/runtime/rpc/methods/browser-schemas.ts index 72a2893444e..9eff1456662 100644 --- a/src/main/runtime/rpc/methods/browser-schemas.ts +++ b/src/main/runtime/rpc/methods/browser-schemas.ts @@ -1,7 +1,5 @@ -// Why: the browser method surface area is large enough that keeping every -// schema in the same file as its handler registration pushes the file past -// the 300-line lint cap. Grouping all browser schemas here keeps each -// handler file focused on dispatch wiring. +// Why: browser schemas stay separate from handler registration so both sides +// remain under the line cap and dispatch wiring stays scannable. import { z } from 'zod' import { BrowserTarget, diff --git a/src/main/runtime/runtime-rpc.test.ts b/src/main/runtime/runtime-rpc.test.ts index 03c8225b44d..1b0a31dd2b0 100644 --- a/src/main/runtime/runtime-rpc.test.ts +++ b/src/main/runtime/runtime-rpc.test.ts @@ -1983,6 +1983,20 @@ describe('OrcaRuntimeRpcServer', () => { (response) => replies.push(JSON.parse(response) as Record), () => {} ) + await server['handleWebSocketMessage']( + JSON.stringify({ + id: 'req_browser_certificate_proceed', + method: 'browser.certificate.proceed', + deviceToken: mobile.token, + params: { + worktree: 'id:wt-1', + page: 'page-1', + challengeId: 'challenge-1' + } + }), + (response) => replies.push(JSON.parse(response) as Record), + () => {} + ) await server['handleWebSocketMessage']( JSON.stringify({ id: 'req_browser_dialog_accept', @@ -2123,6 +2137,13 @@ describe('OrcaRuntimeRpcServer', () => { expect(replies).toContainEqual( expect.objectContaining({ id: 'req_browser_viewport', ok: true }) ) + expect(replies).toContainEqual( + expect.objectContaining({ + id: 'req_browser_certificate_proceed', + ok: false, + error: expect.objectContaining({ code: 'forbidden' }) + }) + ) expect(replies).toContainEqual( expect.objectContaining({ id: 'req_browser_dialog_accept', ok: true }) ) diff --git a/src/preload/api-types.ts b/src/preload/api-types.ts index a3db4bbbc55..7c03b6f8879 100644 --- a/src/preload/api-types.ts +++ b/src/preload/api-types.ts @@ -67,6 +67,8 @@ import type { BaseRefDefaultResult, BaseRefSearchResult, BrowserCookieImportResult, + BrowserCertificateFailure, + BrowserCertificateProceedResult, BrowserLoadError, BrowserSessionProfile, BrowserSessionProfileScope, @@ -479,7 +481,7 @@ export type BrowserApi = { worktreeId: string sessionProfileId?: string | null webContentsId: number - }) => Promise + }) => Promise unregisterGuest: (args: { browserPageId: string }) => Promise openDevTools: (args: { browserPageId: string }) => Promise setViewportOverride: (args: { @@ -490,6 +492,13 @@ export type BrowserApi = { onGuestLoadFailed: ( callback: (args: { browserPageId: string; loadError: BrowserLoadError }) => void ) => () => void + onCertificateFailureChanged: ( + callback: (event: { browserPageId: string; failure: BrowserCertificateFailure | null }) => void + ) => () => void + proceedCertificate: (args: { + browserPageId: string + challengeId: string + }) => Promise onPermissionDenied: (callback: (event: BrowserPermissionDeniedEvent) => void) => () => void onPopup: (callback: (event: BrowserPopupEvent) => void) => () => void onDownloadRequested: (callback: (event: BrowserDownloadRequestedEvent) => void) => () => void diff --git a/src/preload/index.ts b/src/preload/index.ts index 4aa685d7b56..9f0a30529bd 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -2238,7 +2238,7 @@ const api = { worktreeId: string sessionProfileId?: string | null webContentsId: number - }): Promise => ipcRenderer.invoke('browser:registerGuest', args), + }): Promise => ipcRenderer.invoke('browser:registerGuest', args), unregisterGuest: (args: { browserPageId: string }): Promise => ipcRenderer.invoke('browser:unregisterGuest', args), @@ -2271,6 +2271,17 @@ const api = { return () => ipcRenderer.removeListener('browser:guest-load-failed', listener) }, + onCertificateFailureChanged: (callback): (() => void) => { + const listener = ( + _event: Electron.IpcRendererEvent, + data: Parameters[0] + ): void => callback(data) + ipcRenderer.on('browser:certificate-failure-changed', listener) + return () => ipcRenderer.removeListener('browser:certificate-failure-changed', listener) + }, + + proceedCertificate: (args) => ipcRenderer.invoke('browser:proceedCertificate', args), + onPermissionDenied: ( callback: (event: { browserPageId: string; permission: string; origin: string }) => void ): (() => void) => { diff --git a/src/renderer/src/components/browser-pane/BrowserPane.tsx b/src/renderer/src/components/browser-pane/BrowserPane.tsx index 20cde69e56f..50b4dc45852 100644 --- a/src/renderer/src/components/browser-pane/BrowserPane.tsx +++ b/src/renderer/src/components/browser-pane/BrowserPane.tsx @@ -63,6 +63,7 @@ import { getRuntimeEnvironmentIdForWorktree } from '@/lib/worktree-runtime-owner import { ORCA_BROWSER_BLANK_URL, ORCA_BROWSER_PARTITION } from '../../../../shared/constants' import { getOrcaProfileBrowserDefaultPartition } from '../../../../shared/orca-profiles' import type { + BrowserCertificateProceedResult, BrowserLoadError, BrowserPage as BrowserPageState, BrowserWorkspace as BrowserWorkspaceState @@ -70,7 +71,9 @@ import type { import { normalizeBrowserNavigationUrl, normalizeExternalBrowserUrl, - redactKagiSessionToken + redactKagiSessionToken, + resolveRemoteFailureExternalUrl, + toHttpsRecoveryUrl } from '../../../../shared/browser-url' import { keybindingMatchesAction } from '../../../../shared/keybindings' import { getScreenSubmitModifierLabel, isScreenSubmitShortcut } from '@/lib/screen-submit-shortcut' @@ -165,13 +168,7 @@ import { type BrowserScreencastFrameMetadata } from '../../../../shared/browser-screencast-protocol' import { withBrowserPaneUiRuntimeRpcSource } from '../../../../shared/runtime-rpc-feature-interaction-source' -import { - formatByteCount, - formatLoadFailureDescription, - formatLoadFailureRecoveryHint, - formatPermissionNotice, - formatPopupNotice -} from './browser-notices' +import { formatByteCount, formatPermissionNotice, formatPopupNotice } from './browser-notices' import { getDriverForBrowserPage, onBrowserDriverChange, @@ -186,6 +183,7 @@ import { useMarkupMode, type MarkupCaptureContext } from './markup/useMarkupMode import { MarkupOverlay } from './markup/MarkupOverlay' import { MarkupDrawButton } from './markup/MarkupDrawButton' import { deliverMarkupToClipboard } from './markup/markup-clipboard-delivery' +import { BrowserLoadFailureOverlay } from './browser-load-failure-overlay' type BrowserTabPageState = Partial< Pick< @@ -700,28 +698,6 @@ function getRemoteBrowserDeviceScaleFactor(): number { return Math.min(2, Math.max(1, Number(scale.toFixed(2)))) } -function getLoadErrorMetadata(loadError: BrowserLoadError | null): { - displayUrl: string - host: string | null - isLocalhostLike: boolean -} { - const rawUrl = loadError?.validatedUrl ?? 'about:blank' - const displayUrl = toDisplayUrl(rawUrl) - try { - const parsed = new URL(rawUrl) - const host = parsed.host || null - const hostname = parsed.hostname - const isLocalhostLike = - hostname === 'localhost' || - hostname === '127.0.0.1' || - hostname === '0.0.0.0' || - hostname === '::1' - return { displayUrl, host, isLocalhostLike } - } catch { - return { displayUrl, host: null, isLocalhostLike: false } - } -} - function getOpenableExternalUrl( webview: Electron.WebviewTag | null, fallbackUrl: string @@ -960,6 +936,12 @@ function RemoteBrowserPagePane({ (token: RemoteBrowserOperationToken) => Promise >(async () => null) const setRemoteBrowserPageHandle = useAppStore((s) => s.setRemoteBrowserPageHandle) + const certificateFailure = useAppStore( + (s) => s.browserCertificateFailuresByPageId[browserTab.id] ?? null + ) + const remotePageHandle = useAppStore( + (s) => s.remoteBrowserPageHandlesByPageId[browserTab.id] ?? null + ) const createBrowserTab = useAppStore((s) => s.createBrowserTab) const closeBrowserPage = useAppStore((s) => s.closeBrowserPage) const closeBrowserTab = useAppStore((s) => s.closeBrowserTab) @@ -1985,7 +1967,13 @@ function RemoteBrowserPagePane({ setRemoteError(message) onUpdatePageState(browserTab.id, { loading: false, - loadError: { code: 0, description: message, validatedUrl: url ?? browserTab.url } + // Why: validatedUrl crosses process/persistence boundaries, so redact a + // Kagi session token the same way the main-process failure path does. + loadError: { + code: 0, + description: message, + validatedUrl: redactKagiSessionToken(url ?? browserTab.url) + } }) } finally { if (isCurrentRemoteOperationToken(pageToken)) { @@ -2398,6 +2386,12 @@ function RemoteBrowserPagePane({ }, [frameUrl, handleRemoteScreenshotWheel]) const remoteFrameStyle = useMemo(() => getRemoteBrowserFrameStyle(frameMetadata), [frameMetadata]) + const remoteFailureUrl = browserTab.loadError?.validatedUrl ?? browserTab.url + const remoteFailureExternalUrl = resolveRemoteFailureExternalUrl(remoteFailureUrl) + const showRemoteFailureOverlay = + Boolean(browserTab.loadError) && + remoteFailureUrl !== 'about:blank' && + remoteFailureUrl !== ORCA_BROWSER_BLANK_URL // Why: markup works on remote panes by snapshotting the already-displayed // screencast — no in-page injection needed, so it is enabled here even @@ -2692,6 +2686,44 @@ function RemoteBrowserPagePane({ )} + {showRemoteFailureOverlay && browserTab.loadError ? ( + void runRemoteNavigation('browser.reload')} + onTryHttps={(url) => void runRemoteNavigation('browser.goto', url)} + onCopy={(url) => void window.api.ui.writeClipboardText(url)} + onOpenExternal={(url) => void window.api.shell.openUrl(url)} + certificateFailure={certificateFailure} + expectedBrowserPageId={ + remotePageHandle?.environmentId === activeRuntimeEnvironmentId + ? remotePageHandle.remotePageId + : null + } + onProceedCertificate={async (challengeId) => { + const target = runtimeTarget() + if ( + !target || + remotePageHandle?.environmentId !== target.environmentId || + remotePageHandle.remotePageId !== certificateFailure?.browserPageId + ) { + return { ok: false, reason: 'missing' } + } + return callRuntimeRpc( + target, + 'browser.certificate.proceed', + { + worktree: runtimeWorktree, + page: remotePageHandle.remotePageId, + challengeId + }, + { timeoutMs: 15_000, suppressFeatureInteraction: true } + ) + }} + /> + ) : null} {remoteError ? (
{remoteError} @@ -2879,6 +2911,9 @@ function BrowserPagePane({ const browserAnnotations = useAppStore( (s) => s.browserAnnotationsByPageId[browserTab.id] ?? EMPTY_BROWSER_ANNOTATIONS ) + const certificateFailure = useAppStore( + (s) => s.browserCertificateFailuresByPageId[browserTab.id] ?? null + ) const activeGroupId = useAppStore((s) => s.activeGroupIdByWorktree[worktreeId]) const browserAnnotationsRef = useRef(browserAnnotations) browserAnnotationsRef.current = browserAnnotations @@ -3731,21 +3766,53 @@ function BrowserPagePane({ dismissAddressBarSuggestionsRef.current?.() } - const handleDomReady = (): void => { + let registrationInFlight: { webContentsId: number; promise: Promise } | null = null + const registerGuest = (): Promise => { const webContentsId = webview.getWebContentsId() - let queuedAnnotationViewportBridgeSync = false - if (registeredWebContentsIds.get(browserTab.id) !== webContentsId) { - registeredWebContentsIds.set(browserTab.id, webContentsId) - queuedAnnotationViewportBridgeSync = true - void window.api.browser - .registerGuest({ - browserPageId: browserTab.id, - workspaceId, - worktreeId, - sessionProfileId, - webContentsId - }) - .finally(() => syncBrowserAnnotationViewportBridge()) + if (registeredWebContentsIds.get(browserTab.id) === webContentsId) { + return Promise.resolve(true) + } + if (registrationInFlight?.webContentsId === webContentsId) { + return registrationInFlight.promise + } + const promise = window.api.browser + .registerGuest({ + browserPageId: browserTab.id, + workspaceId, + worktreeId, + sessionProfileId, + webContentsId + }) + .then((registered) => { + if (registered) { + registeredWebContentsIds.set(browserTab.id, webContentsId) + } + return registered + }) + // Why: normalize IPC rejection to false so the dom-ready fallback can + // retry attach-policy races without an unhandled promise rejection. + .catch(() => false) + .finally(() => { + if (registrationInFlight?.promise === promise) { + registrationInFlight = null + } + }) + registrationInFlight = { webContentsId, promise } + return promise + } + + const handleDidAttach = (): void => { + // Why: certificate failures can happen before dom-ready. Register at + // attach so main can map the initial failure to this page; dom-ready below + // remains an idempotent fallback for attach-policy races. + void registerGuest().finally(() => syncBrowserAnnotationViewportBridge()) + } + + const handleDomReady = (): void => { + const queuedAnnotationViewportBridgeSync = + registeredWebContentsIds.get(browserTab.id) !== webview.getWebContentsId() + if (queuedAnnotationViewportBridgeSync) { + void registerGuest().finally(() => syncBrowserAnnotationViewportBridge()) } syncNavigationState(webview) if (keepAddressBarFocusRef.current) { @@ -3964,6 +4031,7 @@ function BrowserPagePane({ } } + webview.addEventListener('did-attach', handleDidAttach) webview.addEventListener('dom-ready', handleDomReady) webview.addEventListener('focus', dismissAddressBarSuggestions) webview.addEventListener('did-start-loading', handleDidStartLoading) @@ -3997,6 +4065,7 @@ function BrowserPagePane({ } return () => { + webview.removeEventListener('did-attach', handleDidAttach) webview.removeEventListener('dom-ready', handleDomReady) webview.removeEventListener('focus', dismissAddressBarSuggestions) webview.removeEventListener('did-start-loading', handleDidStartLoading) @@ -4672,8 +4741,8 @@ function BrowserPagePane({ const isBlankTab = browserTab.url === 'about:blank' || browserTab.url === ORCA_BROWSER_BLANK_URL const externalUrl = getOpenableExternalUrl(webviewRef.current, browserTab.url) const currentBrowserUrl = getCurrentBrowserUrl(webviewRef.current, browserTab.url) - const loadErrorMeta = getLoadErrorMetadata(browserTab.loadError) - const loadErrorHint = formatLoadFailureRecoveryHint(loadErrorMeta) + const failedNavigationUrl = browserTab.loadError?.validatedUrl ?? currentBrowserUrl + const failureExternalUrl = normalizeExternalBrowserUrl(failedNavigationUrl) const showFailureOverlay = Boolean(browserTab.loadError) && !isBlankTab const visibleDownloads = (() => { const active = downloadStates.filter((download) => download.status === 'downloading') @@ -5476,114 +5545,40 @@ function BrowserPagePane({ onClose={() => setFindOpen(false)} webviewRef={webviewRef} /> - {showFailureOverlay ? ( -
-
-
- -
-

- {loadErrorMeta.host - ? translate( - 'auto.components.browser.pane.BrowserPane.db325a7eeb', - "Can't reach {{value0}}", - { value0: loadErrorMeta.host } - ) - : translate( - 'auto.components.browser.pane.BrowserPane.b2856516e2', - "Can't load this page" - )} -

-

- {formatLoadFailureDescription(browserTab.loadError, loadErrorMeta)} -

- {loadErrorHint ? ( -

{loadErrorHint}

- ) : null} -
- - - {externalUrl ? ( - - ) : null} -
-
-
+ {showFailureOverlay && browserTab.loadError ? ( + { + const webview = webviewRef.current + if (!webview) { + return + } + onUpdatePageStateRef.current(browserTab.id, { loading: true }) + retryBrowserTabLoad(webview, browserTab, onUpdatePageStateRef.current) + }} + onTryHttps={navigateToUrl} + onCopy={(url) => { + void window.api.ui.writeClipboardText(url) + setResourceNotice( + translate( + 'browser.loadFailure.addressCopied', + 'Copied the current page address.' + ) + ) + }} + onOpenExternal={(url) => void window.api.shell.openUrl(url)} + certificateFailure={certificateFailure} + expectedBrowserPageId={browserTab.id} + onProceedCertificate={(challengeId) => + window.api.browser.proceedCertificate({ + browserPageId: browserTab.id, + challengeId + }) + } + /> ) : null} {isBlankTab ? (
diff --git a/src/renderer/src/components/browser-pane/browser-load-failure-overlay.test.tsx b/src/renderer/src/components/browser-pane/browser-load-failure-overlay.test.tsx new file mode 100644 index 00000000000..d49e8ccdc9f --- /dev/null +++ b/src/renderer/src/components/browser-pane/browser-load-failure-overlay.test.tsx @@ -0,0 +1,319 @@ +// @vitest-environment happy-dom +import '@testing-library/jest-dom/vitest' +import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' +import { afterEach, describe, expect, it, vi } from 'vitest' + +import { BrowserLoadFailureOverlay } from './browser-load-failure-overlay' + +const callbacks = { + onRetry: vi.fn(), + onTryHttps: vi.fn(), + onCopy: vi.fn(), + onOpenExternal: vi.fn(), + onProceedCertificate: vi.fn() +} + +const certificateFailure = { + challengeId: 'challenge-1', + browserPageId: 'page-1', + errorCode: -202, + error: 'ERR_CERT_AUTHORITY_INVALID', + origin: 'https://localhost:3443', + displayHost: 'localhost:3443', + canProceed: true, + observedAt: 123 +} + +afterEach(() => { + cleanup() + vi.useRealTimers() + vi.clearAllMocks() +}) + +describe('BrowserLoadFailureOverlay', () => { + it('uses certificate-specific copy while keeping strict verification', () => { + render( + + ) + + expect(screen.getByText("Connection isn't secure")).toBeInTheDocument() + expect( + screen.getByText( + "Orca doesn't trust the authority that issued the certificate for localhost:3443." + ) + ).toBeInTheDocument() + expect(screen.queryByRole('button', { name: 'Proceed Anyway (Unsafe)' })).toBeNull() + expect(screen.queryByText(/make sure the server is running/i)).not.toBeInTheDocument() + }) + + it('keeps restored certificate errors accurate without allowing approval', () => { + render( + + ) + + expect(screen.getByText("The certificate doesn't match localhost:3443.")).toBeInTheDocument() + expect(screen.queryByRole('button', { name: 'Proceed Anyway (Unsafe)' })).toBeNull() + expect(screen.getByRole('button', { name: 'Retry' })).toBeEnabled() + expect(screen.getByRole('button', { name: 'Copy Address' })).toBeEnabled() + }) + + it('offers HTTPS recovery only for an eligible failed HTTP address', () => { + const { rerender } = render( + + ) + + fireEvent.click(screen.getByRole('button', { name: 'Try HTTPS' })) + expect(callbacks.onTryHttps).toHaveBeenCalledWith('https://localhost:3000/app') + + rerender( + + ) + expect(screen.queryByRole('button', { name: 'Try HTTPS' })).toBeNull() + }) + + it('hides Open Externally and works without an external handler', () => { + render( + + ) + + // externalUrl / onOpenExternal omitted (e.g. a remote loopback failure): + // the action is absent and the overlay still renders its other recovery UI. + expect(screen.queryByRole('button', { name: 'Open Externally' })).toBeNull() + expect(screen.getByRole('button', { name: 'Retry' })).toBeEnabled() + expect(screen.getByRole('button', { name: 'Copy Address' })).toBeEnabled() + }) + + it('does not show local-certificate guidance for a public-host failure', () => { + render( + + ) + + expect(screen.queryByText(/use a trusted local certificate/i)).not.toBeInTheDocument() + }) + + it('offers approval only for a matching live challenge', () => { + const { rerender } = render( + + ) + + expect(screen.getByRole('button', { name: 'Proceed Anyway (Unsafe)' })).toBeEnabled() + + rerender( + + ) + expect(screen.getByText("Connection isn't secure")).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'Proceed Anyway (Unsafe)' })).toBeEnabled() + + rerender( + + ) + expect(screen.queryByRole('button', { name: 'Proceed Anyway (Unsafe)' })).toBeNull() + + rerender( + + ) + expect(screen.queryByRole('button', { name: 'Proceed Anyway (Unsafe)' })).toBeNull() + }) + + it('disables every action immediately and delays connecting feedback', async () => { + vi.useFakeTimers() + let resolveProceed: ((result: { ok: true }) => void) | null = null + callbacks.onProceedCertificate.mockReturnValue( + new Promise((resolve) => { + resolveProceed = resolve + }) + ) + const { rerender } = render( + + ) + + fireEvent.click(screen.getByRole('button', { name: 'Proceed Anyway (Unsafe)' })) + expect(callbacks.onProceedCertificate).toHaveBeenCalledWith('challenge-1') + for (const name of ['Proceed Anyway (Unsafe)', 'Open Externally', 'Retry', 'Copy Address']) { + expect(screen.getByRole('button', { name })).toBeDisabled() + } + expect(screen.queryByText('Connecting…')).toBeNull() + + act(() => vi.advanceTimersByTime(199)) + expect(screen.queryByText('Connecting…')).toBeNull() + act(() => vi.advanceTimersByTime(1)) + expect(screen.getByRole('button', { name: 'Connecting…' })).toBeDisabled() + + await act(async () => resolveProceed?.({ ok: true })) + rerender( + + ) + expect(screen.queryByRole('button', { name: 'Proceed Anyway (Unsafe)' })).toBeNull() + expect(screen.getByRole('button', { name: 'Retry' })).toBeEnabled() + }) + + it('recovers from typed approval failures and resets for a new challenge', async () => { + callbacks.onProceedCertificate.mockResolvedValue({ ok: false, reason: 'expired' }) + const props = { + loadError: { + code: -202, + description: 'ERR_CERT_AUTHORITY_INVALID', + validatedUrl: 'https://localhost:3443/app' + }, + externalUrl: null, + currentUrl: 'https://localhost:3443/app', + httpsRecoveryUrl: null, + expectedBrowserPageId: 'page-1', + ...callbacks + } + const { rerender } = render( + + ) + + fireEvent.click(screen.getByRole('button', { name: 'Proceed Anyway (Unsafe)' })) + await waitFor(() => + expect(screen.getByRole('alert')).toHaveTextContent(/certificate approval expired/i) + ) + expect(screen.getByRole('button', { name: 'Retry' })).toBeEnabled() + + callbacks.onProceedCertificate.mockReturnValue(new Promise(() => {})) + fireEvent.click(screen.getByRole('button', { name: 'Proceed Anyway (Unsafe)' })) + expect(screen.getByRole('button', { name: 'Retry' })).toBeDisabled() + rerender( + + ) + expect(screen.getByRole('button', { name: 'Proceed Anyway (Unsafe)' })).toBeEnabled() + expect(screen.queryByRole('alert')).toBeNull() + }) +}) diff --git a/src/renderer/src/components/browser-pane/browser-load-failure-overlay.tsx b/src/renderer/src/components/browser-pane/browser-load-failure-overlay.tsx new file mode 100644 index 00000000000..06fa7249108 --- /dev/null +++ b/src/renderer/src/components/browser-pane/browser-load-failure-overlay.tsx @@ -0,0 +1,340 @@ +import { useEffect, useRef, useState } from 'react' +import { Copy, ExternalLink, Globe, Loader2, RefreshCw, ShieldAlert } from 'lucide-react' + +import { Button } from '@/components/ui/button' +import { translate } from '@/i18n/i18n' +import type { + BrowserCertificateFailure, + BrowserCertificateProceedFailureReason, + BrowserCertificateProceedResult, + BrowserLoadError +} from '../../../../shared/types' +import { isEligibleLocalCertificateHost } from '../../../../shared/browser-url' +import { + formatLoadFailureDescription, + formatLoadFailureRecoveryHint, + isCertificateLoadError, + type LoadFailureMeta +} from './browser-notices' + +type BrowserLoadFailureOverlayProps = { + loadError: BrowserLoadError + externalUrl?: string | null + currentUrl: string + httpsRecoveryUrl: string | null + onRetry: () => void + onTryHttps: (url: string) => void + onCopy: (url: string) => void + onOpenExternal?: (url: string) => void + certificateFailure?: BrowserCertificateFailure | null + expectedBrowserPageId?: string | null + onProceedCertificate?: (challengeId: string) => Promise +} + +type CertificateProceedAttempt = { + challengeId: string + state: 'submitting' | 'failed' + showConnecting: boolean + reason?: BrowserCertificateProceedFailureReason | 'request-failed' +} + +function getLoadErrorMetadata(loadError: BrowserLoadError): LoadFailureMeta { + try { + const parsed = new URL(loadError.validatedUrl) + return { + host: parsed.host || null, + isLocalhostLike: + parsed.hostname === '0.0.0.0' || isEligibleLocalCertificateHost(parsed.hostname) + } + } catch { + return { host: null, isLocalhostLike: false } + } +} + +function normalizeCertificateError(error: string): string { + return error + .trim() + .replace(/^net::/i, '') + .toUpperCase() +} + +function getMatchingCertificateFailure(args: { + loadError: BrowserLoadError + certificateFailure?: BrowserCertificateFailure | null + expectedBrowserPageId?: string | null + canProceed: boolean +}): BrowserCertificateFailure | null { + const { loadError, certificateFailure, expectedBrowserPageId, canProceed } = args + const errorMatchesChallenge = Boolean( + certificateFailure && + (loadError.code === -1 || + (loadError.code === certificateFailure.errorCode && + normalizeCertificateError(certificateFailure.error) === + normalizeCertificateError(loadError.description))) + ) + if ( + !canProceed || + !certificateFailure?.canProceed || + !expectedBrowserPageId || + certificateFailure.browserPageId !== expectedBrowserPageId || + certificateFailure.errorCode !== -202 || + !errorMatchesChallenge + ) { + return null + } + try { + return new URL(loadError.validatedUrl).origin === certificateFailure.origin + ? certificateFailure + : null + } catch { + return null + } +} + +function formatCertificateProceedFailure( + reason: BrowserCertificateProceedFailureReason | 'request-failed' +): string { + if (reason === 'expired') { + return translate( + 'browser.loadFailure.certificateChallengeExpired', + 'This certificate approval expired. Retry the page to request a new one.' + ) + } + if (reason === 'changed' || reason === 'navigated') { + return translate( + 'browser.loadFailure.certificateChallengeChanged', + 'The certificate request changed. Retry the page and review the new warning.' + ) + } + if (reason === 'request-failed') { + return translate( + 'browser.loadFailure.certificateProceedFailed', + 'Orca could not approve this certificate request. Retry the page and try again.' + ) + } + return translate( + 'browser.loadFailure.certificateChallengeUnavailable', + 'This certificate request is no longer available. Retry the page to request a new one.' + ) +} + +export function BrowserLoadFailureOverlay({ + loadError, + externalUrl, + currentUrl, + httpsRecoveryUrl, + onRetry, + onTryHttps, + onCopy, + onOpenExternal, + certificateFailure, + expectedBrowserPageId, + onProceedCertificate +}: BrowserLoadFailureOverlayProps): React.JSX.Element { + const connectingTimerRef = useRef<{ + challengeId: string + timer: ReturnType + } | null>(null) + const [proceedAttempt, setProceedAttempt] = useState(null) + const matchingCertificateFailure = getMatchingCertificateFailure({ + loadError, + certificateFailure, + expectedBrowserPageId, + canProceed: Boolean(onProceedCertificate) + }) + // Why: Chromium's error-page fallback can replace the diagnostic code with + // -1 after main has already captured an exact live certificate challenge. + const presentationLoadError = + matchingCertificateFailure && loadError.code === -1 + ? { + ...loadError, + code: matchingCertificateFailure.errorCode ?? loadError.code, + description: matchingCertificateFailure.error + } + : loadError + const meta = getLoadErrorMetadata(presentationLoadError) + const certificateError = isCertificateLoadError(presentationLoadError) + const recoveryHint = formatLoadFailureRecoveryHint(meta, presentationLoadError) + const activeProceedAttempt = + proceedAttempt?.challengeId === matchingCertificateFailure?.challengeId ? proceedAttempt : null + const actionsDisabled = activeProceedAttempt?.state === 'submitting' + + useEffect(() => { + return () => { + if (connectingTimerRef.current) { + clearTimeout(connectingTimerRef.current.timer) + connectingTimerRef.current = null + } + } + }, [matchingCertificateFailure?.challengeId]) + + const proceedCertificate = (): void => { + if (!matchingCertificateFailure || !onProceedCertificate || actionsDisabled) { + return + } + const challengeId = matchingCertificateFailure.challengeId + setProceedAttempt({ challengeId, state: 'submitting', showConnecting: false }) + connectingTimerRef.current = { + challengeId, + timer: setTimeout(() => { + setProceedAttempt((current) => + current?.challengeId === challengeId && current.state === 'submitting' + ? { ...current, showConnecting: true } + : current + ) + }, 200) + } + void onProceedCertificate(challengeId) + .then((result) => { + if (connectingTimerRef.current?.challengeId === challengeId) { + clearTimeout(connectingTimerRef.current.timer) + connectingTimerRef.current = null + } + if (!result.ok) { + setProceedAttempt((current) => + current?.challengeId === challengeId + ? { + challengeId, + state: 'failed', + showConnecting: false, + reason: result.reason + } + : current + ) + } + }) + .catch(() => { + if (connectingTimerRef.current?.challengeId === challengeId) { + clearTimeout(connectingTimerRef.current.timer) + connectingTimerRef.current = null + } + setProceedAttempt((current) => + current?.challengeId === challengeId + ? { + challengeId, + state: 'failed', + showConnecting: false, + reason: 'request-failed' + } + : current + ) + }) + } + const retryButton = ( + + ) + const copyButton = ( + + ) + const externalButton = + externalUrl && onOpenExternal ? ( + + ) : null + + return ( +
+
+
+ {certificateError ? : } +
+

+ {certificateError + ? translate('browser.loadFailure.connectionNotSecure', "Connection isn't secure") + : meta.host + ? translate('browser.loadFailure.cantReachHost', "Can't reach {{value0}}", { + value0: meta.host + }) + : translate('browser.loadFailure.cantLoadPage', "Can't load this page")} +

+

+ {formatLoadFailureDescription(presentationLoadError, meta)} +

+ {certificateError && meta.isLocalhostLike ? ( +

+ {translate( + 'browser.loadFailure.trustedCertificateGuidance', + 'For local development, use a trusted local certificate when possible.' + )} +

+ ) : null} + {recoveryHint ?

{recoveryHint}

: null} + {activeProceedAttempt?.state === 'failed' && activeProceedAttempt.reason ? ( +

+ {formatCertificateProceedFailure(activeProceedAttempt.reason)} +

+ ) : null} +
+ {certificateError ? ( + <> + {externalButton} + {retryButton} + {copyButton} + {matchingCertificateFailure ? ( + + ) : null} + + ) : ( + <> + {httpsRecoveryUrl ? ( + + ) : null} + {retryButton} + {copyButton} + {externalButton} + + )} +
+
+
+ ) +} diff --git a/src/renderer/src/components/browser-pane/browser-notices.test.ts b/src/renderer/src/components/browser-pane/browser-notices.test.ts index b08bca7ba3c..24f31d3866a 100644 --- a/src/renderer/src/components/browser-pane/browser-notices.test.ts +++ b/src/renderer/src/components/browser-pane/browser-notices.test.ts @@ -5,7 +5,8 @@ import { formatLoadFailureDescription, formatLoadFailureRecoveryHint, formatPermissionNotice, - formatPopupNotice + formatPopupNotice, + isCertificateLoadError } from './browser-notices' describe('browser notice formatting', () => { @@ -114,4 +115,29 @@ describe('browser notice formatting', () => { }) ).toBeNull() }) + + it('formats certificate failures without local-server recovery advice', () => { + const meta = { host: 'localhost:3443', isLocalhostLike: true } + const loadError = (code: number) => ({ + code, + description: 'certificate error', + validatedUrl: 'https://localhost:3443/' + }) + + expect(formatLoadFailureDescription(loadError(-200), meta)).toBe( + "The certificate doesn't match localhost:3443." + ) + expect(formatLoadFailureDescription(loadError(-201), meta)).toBe( + "The certificate for localhost:3443 isn't valid at the current date and time." + ) + expect(formatLoadFailureDescription(loadError(-202), meta)).toBe( + "Orca doesn't trust the authority that issued the certificate for localhost:3443." + ) + expect(formatLoadFailureDescription(loadError(-208), meta)).toBe( + "Orca couldn't verify the certificate for localhost:3443." + ) + expect(isCertificateLoadError(loadError(-219))).toBe(true) + expect(isCertificateLoadError(loadError(-215))).toBe(false) + expect(formatLoadFailureRecoveryHint(meta, loadError(-202))).toBeNull() + }) }) diff --git a/src/renderer/src/components/browser-pane/browser-notices.ts b/src/renderer/src/components/browser-pane/browser-notices.ts index 81e31c8d81b..8c7b22800e0 100644 --- a/src/renderer/src/components/browser-pane/browser-notices.ts +++ b/src/renderer/src/components/browser-pane/browser-notices.ts @@ -4,8 +4,10 @@ import type { BrowserPopupEvent } from '../../../../shared/browser-guest-events' import type { BrowserLoadError } from '../../../../shared/types' +import { isChromiumCertificateErrorCode } from '../../../../shared/browser-certificate-errors' +import { translate } from '@/i18n/i18n' -type LoadFailureMeta = { +export type LoadFailureMeta = { host: string | null isLocalhostLike: boolean } @@ -73,6 +75,35 @@ export function formatLoadFailureDescription( if (!loadError) { return 'The page did not respond.' } + if (isChromiumCertificateErrorCode(loadError.code)) { + const host = meta.host ?? 'this address' + if (loadError.code === -200) { + return translate( + 'browser.loadFailure.certificateNameMismatch', + "The certificate doesn't match {{value0}}.", + { value0: host } + ) + } + if (loadError.code === -201) { + return translate( + 'browser.loadFailure.certificateDateInvalid', + "The certificate for {{value0}} isn't valid at the current date and time.", + { value0: host } + ) + } + if (loadError.code === -202) { + return translate( + 'browser.loadFailure.certificateAuthorityInvalid', + "Orca doesn't trust the authority that issued the certificate for {{value0}}.", + { value0: host } + ) + } + return translate( + 'browser.loadFailure.certificateVerificationFailed', + "Orca couldn't verify the certificate for {{value0}}.", + { value0: host } + ) + } if (meta.isLocalhostLike) { return "We couldn't connect to your local server." } @@ -82,9 +113,16 @@ export function formatLoadFailureDescription( return "We couldn't connect to this page." } -export function formatLoadFailureRecoveryHint(meta: LoadFailureMeta): string | null { - if (!meta.isLocalhostLike) { +export function formatLoadFailureRecoveryHint( + meta: LoadFailureMeta, + loadError?: BrowserLoadErrorLike +): string | null { + if (!meta.isLocalhostLike || (loadError && isChromiumCertificateErrorCode(loadError.code))) { return null } return 'If this should be a local app, make sure the server is running and listening on the expected port.' } + +export function isCertificateLoadError(loadError: BrowserLoadErrorLike): boolean { + return Boolean(loadError && isChromiumCertificateErrorCode(loadError.code)) +} diff --git a/src/renderer/src/components/tab-bar/tab-create-entry-classifier.test.ts b/src/renderer/src/components/tab-bar/tab-create-entry-classifier.test.ts index 3dae3be6826..f46d958aee0 100644 --- a/src/renderer/src/components/tab-bar/tab-create-entry-classifier.test.ts +++ b/src/renderer/src/components/tab-bar/tab-create-entry-classifier.test.ts @@ -50,6 +50,15 @@ describe('tab create entry classification', () => { }) }) + it('keeps the shared legacy local-dev forms in parity with address-bar normalization', () => { + for (const input of ['0.0.0.0:3000', '[::1]:3000', '[2001:db8::1]:3000/path']) { + expect(classifyTabEntryQuery(input, readyFiles([]))).toMatchObject({ + kind: 'host-url', + url: expect.stringMatching(/^http:/) + }) + } + }) + it('does not classify invalid numeric hosts as URLs', () => { expect(classifyTabEntryQuery('999.999.999.999', readyFiles([]))).toEqual({ kind: 'new-file', diff --git a/src/renderer/src/components/tab-bar/tab-create-entry-url-classification.ts b/src/renderer/src/components/tab-bar/tab-create-entry-url-classification.ts index 2f1ddfeccb4..375e5d71764 100644 --- a/src/renderer/src/components/tab-bar/tab-create-entry-url-classification.ts +++ b/src/renderer/src/components/tab-bar/tab-create-entry-url-classification.ts @@ -1,4 +1,5 @@ import { translate } from '@/i18n/i18n' +import { classifySchemeLessLocalDevAddress } from '../../../../shared/browser-url' const HOST_FILE_EXTENSIONS = new Set([ 'css', @@ -15,9 +16,6 @@ const HOST_FILE_EXTENSIONS = new Set([ 'yml' ]) -const LOCAL_ADDRESS_PATTERN = - /^(?:localhost|127(?:\.\d{1,3}){3}|0\.0\.0\.0|\[[0-9a-f:]+\])(?::\d+)?(?:[/?#].*)?$/i - const LOCALHOST_WITH_PORT_PATTERN = /^localhost(?::\d{1,5})?$/i const IPV4_WITH_PORT_PATTERN = /^(?:(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)\.){3}(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(?::\d{1,5})?$/ @@ -33,7 +31,7 @@ export type HostUrlClassification = { kind: 'host-url'; url: string } export function classifyExplicitUrl(query: string): ExplicitUrlClassification | null { // Local dev inputs are handled by host-url classification so users can // enter localhost/IP addresses without an explicit scheme. - if (LOCAL_ADDRESS_PATTERN.test(query)) { + if (classifySchemeLessLocalDevAddress(query)) { return null } let url: URL @@ -55,15 +53,8 @@ export function classifyExplicitUrl(query: string): ExplicitUrlClassification | } function classifyLocalDevUrl(query: string): HostUrlClassification | null { - if (!LOCAL_ADDRESS_PATTERN.test(query)) { - return null - } - try { - const url = new URL(`http://${query}`) - return url.hostname ? { kind: 'host-url', url: url.href } : null - } catch { - return null - } + const url = classifySchemeLessLocalDevAddress(query) + return url?.hostname ? { kind: 'host-url', url: url.href } : null } function classifyHostLikeUrl(query: string): HostUrlClassification | null { diff --git a/src/renderer/src/hooks/useIpcEvents.ts b/src/renderer/src/hooks/useIpcEvents.ts index 838cf50720e..2a0fe660165 100644 --- a/src/renderer/src/hooks/useIpcEvents.ts +++ b/src/renderer/src/hooks/useIpcEvents.ts @@ -2108,6 +2108,18 @@ export function useIpcEvents(): void { }) ) + const unsubscribeCertificateFailure = window.api.browser.onCertificateFailureChanged?.( + ({ browserPageId, failure }) => { + if (isRuntimeEnvironmentActive()) { + return + } + useAppStore.getState().setBrowserPageCertificateFailure(browserPageId, failure) + } + ) + if (unsubscribeCertificateFailure) { + unsubs.push(unsubscribeCertificateFailure) + } + // Why: agent-browser drives navigation via CDP, bypassing Electron's webview // event system. The renderer's did-navigate listener never fires for those // navigations, so the Zustand store (address bar, tab title) stays stale. diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 45f8b3fb8bc..38d542e76be 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -7,6 +7,29 @@ "webDescription": "Retry the web client or reconnect to the paired runtime." } }, + "browser": { + "loadFailure": { + "connectionNotSecure": "Connection isn't secure", + "cantReachHost": "Can't reach {{value0}}", + "cantLoadPage": "Can't load this page", + "certificateNameMismatch": "The certificate doesn't match {{value0}}.", + "certificateDateInvalid": "The certificate for {{value0}} isn't valid at the current date and time.", + "certificateAuthorityInvalid": "Orca doesn't trust the authority that issued the certificate for {{value0}}.", + "certificateVerificationFailed": "Orca couldn't verify the certificate for {{value0}}.", + "trustedCertificateGuidance": "For local development, use a trusted local certificate when possible.", + "retry": "Retry", + "copyAddress": "Copy Address", + "addressCopied": "Copied the current page address.", + "openExternally": "Open Externally", + "tryHttps": "Try HTTPS", + "proceedUnsafe": "Proceed Anyway (Unsafe)", + "connecting": "Connecting…", + "certificateChallengeExpired": "This certificate approval expired. Retry the page to request a new one.", + "certificateChallengeChanged": "The certificate request changed. Retry the page and review the new warning.", + "certificateChallengeUnavailable": "This certificate request is no longer available. Retry the page to request a new one.", + "certificateProceedFailed": "Orca could not approve this certificate request. Retry the page and try again." + } + }, "settings": { "appearance": { "language": { diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index 48b2a4b5284..d8d0681e113 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -13284,5 +13284,28 @@ } } } + }, + "browser": { + "loadFailure": { + "connectionNotSecure": "Connection isn't secure", + "cantReachHost": "Can't reach {{value0}}", + "cantLoadPage": "Can't load this page", + "certificateNameMismatch": "The certificate doesn't match {{value0}}.", + "certificateDateInvalid": "The certificate for {{value0}} isn't valid at the current date and time.", + "certificateAuthorityInvalid": "Orca doesn't trust the authority that issued the certificate for {{value0}}.", + "certificateVerificationFailed": "Orca couldn't verify the certificate for {{value0}}.", + "trustedCertificateGuidance": "For local development, use a trusted local certificate when possible.", + "retry": "Retry", + "copyAddress": "Copy Address", + "addressCopied": "Copied the current page address.", + "openExternally": "Open Externally", + "tryHttps": "Try HTTPS", + "proceedUnsafe": "Proceed Anyway (Unsafe)", + "connecting": "Connecting…", + "certificateChallengeExpired": "This certificate approval expired. Retry the page to request a new one.", + "certificateChallengeChanged": "The certificate request changed. Retry the page and review the new warning.", + "certificateChallengeUnavailable": "This certificate request is no longer available. Retry the page to request a new one.", + "certificateProceedFailed": "Orca could not approve this certificate request. Retry the page and try again." + } } } diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index 538b1ef1b61..8b38bf6102e 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -13284,5 +13284,28 @@ } } } + }, + "browser": { + "loadFailure": { + "connectionNotSecure": "Connection isn't secure", + "cantReachHost": "Can't reach {{value0}}", + "cantLoadPage": "Can't load this page", + "certificateNameMismatch": "The certificate doesn't match {{value0}}.", + "certificateDateInvalid": "The certificate for {{value0}} isn't valid at the current date and time.", + "certificateAuthorityInvalid": "Orca doesn't trust the authority that issued the certificate for {{value0}}.", + "certificateVerificationFailed": "Orca couldn't verify the certificate for {{value0}}.", + "trustedCertificateGuidance": "For local development, use a trusted local certificate when possible.", + "retry": "Retry", + "copyAddress": "Copy Address", + "addressCopied": "Copied the current page address.", + "openExternally": "Open Externally", + "tryHttps": "Try HTTPS", + "proceedUnsafe": "Proceed Anyway (Unsafe)", + "connecting": "Connecting…", + "certificateChallengeExpired": "This certificate approval expired. Retry the page to request a new one.", + "certificateChallengeChanged": "The certificate request changed. Retry the page and review the new warning.", + "certificateChallengeUnavailable": "This certificate request is no longer available. Retry the page to request a new one.", + "certificateProceedFailed": "Orca could not approve this certificate request. Retry the page and try again." + } } } diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index ccb2d833551..90745d32358 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -13284,5 +13284,28 @@ } } } + }, + "browser": { + "loadFailure": { + "connectionNotSecure": "Connection isn't secure", + "cantReachHost": "Can't reach {{value0}}", + "cantLoadPage": "Can't load this page", + "certificateNameMismatch": "The certificate doesn't match {{value0}}.", + "certificateDateInvalid": "The certificate for {{value0}} isn't valid at the current date and time.", + "certificateAuthorityInvalid": "Orca doesn't trust the authority that issued the certificate for {{value0}}.", + "certificateVerificationFailed": "Orca couldn't verify the certificate for {{value0}}.", + "trustedCertificateGuidance": "For local development, use a trusted local certificate when possible.", + "retry": "Retry", + "copyAddress": "Copy Address", + "addressCopied": "Copied the current page address.", + "openExternally": "Open Externally", + "tryHttps": "Try HTTPS", + "proceedUnsafe": "Proceed Anyway (Unsafe)", + "connecting": "Connecting…", + "certificateChallengeExpired": "This certificate approval expired. Retry the page to request a new one.", + "certificateChallengeChanged": "The certificate request changed. Retry the page and review the new warning.", + "certificateChallengeUnavailable": "This certificate request is no longer available. Retry the page to request a new one.", + "certificateProceedFailed": "Orca could not approve this certificate request. Retry the page and try again." + } } } diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index 86769650574..d6cd49a0234 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -13284,5 +13284,28 @@ } } } + }, + "browser": { + "loadFailure": { + "connectionNotSecure": "Connection isn't secure", + "cantReachHost": "Can't reach {{value0}}", + "cantLoadPage": "Can't load this page", + "certificateNameMismatch": "The certificate doesn't match {{value0}}.", + "certificateDateInvalid": "The certificate for {{value0}} isn't valid at the current date and time.", + "certificateAuthorityInvalid": "Orca doesn't trust the authority that issued the certificate for {{value0}}.", + "certificateVerificationFailed": "Orca couldn't verify the certificate for {{value0}}.", + "trustedCertificateGuidance": "For local development, use a trusted local certificate when possible.", + "retry": "Retry", + "copyAddress": "Copy Address", + "addressCopied": "Copied the current page address.", + "openExternally": "Open Externally", + "tryHttps": "Try HTTPS", + "proceedUnsafe": "Proceed Anyway (Unsafe)", + "connecting": "Connecting…", + "certificateChallengeExpired": "This certificate approval expired. Retry the page to request a new one.", + "certificateChallengeChanged": "The certificate request changed. Retry the page and review the new warning.", + "certificateChallengeUnavailable": "This certificate request is no longer available. Retry the page to request a new one.", + "certificateProceedFailed": "Orca could not approve this certificate request. Retry the page and try again." + } } } diff --git a/src/renderer/src/runtime/remote-server-parity.test.ts b/src/renderer/src/runtime/remote-server-parity.test.ts index 6122838dc81..dceb8ad7ab5 100644 --- a/src/renderer/src/runtime/remote-server-parity.test.ts +++ b/src/renderer/src/runtime/remote-server-parity.test.ts @@ -48,6 +48,7 @@ function makeState(overrides: Partial = {}): WebSession activeWorktreeId: WT, agentStatusByPaneKey: {}, agentStatusEpoch: 0, + browserCertificateFailuresByPageId: {}, browserPagesByWorkspace: {}, browserTabsByWorktree: {}, groupsByWorktree: {}, diff --git a/src/renderer/src/runtime/sync-runtime-graph-browser.test.ts b/src/renderer/src/runtime/sync-runtime-graph-browser.test.ts index c57ceee92ce..4042e70c525 100644 --- a/src/renderer/src/runtime/sync-runtime-graph-browser.test.ts +++ b/src/renderer/src/runtime/sync-runtime-graph-browser.test.ts @@ -20,6 +20,7 @@ function makeState(overrides: Partial = {}): AppState { activeBrowserTabIdByWorktree: {}, browserTabsByWorktree: {}, browserPagesByWorkspace: {}, + browserCertificateFailuresByPageId: {}, openFiles: [], editorDrafts: {}, activeTabId: null, @@ -103,7 +104,19 @@ describe('browser mobile session sync', () => { createdAt: 1 } ] - } as unknown as AppState['browserPagesByWorkspace'] + } as unknown as AppState['browserPagesByWorkspace'], + browserCertificateFailuresByPageId: { + 'page-1': { + challengeId: 'challenge-1', + browserPageId: 'page-1', + errorCode: -202, + error: 'ERR_CERT_AUTHORITY_INVALID', + origin: 'https://localhost:3443', + displayHost: 'localhost:3443', + canProceed: true, + observedAt: 123 + } + } }) expect(buildMobileSessionTabSnapshots(state)[0]?.tabs).toMatchObject([ @@ -115,11 +128,40 @@ describe('browser mobile session sync', () => { title: 'Example Page', url: 'https://example.com/path', canGoBack: true, + certificateFailure: { + challengeId: 'challenge-1', + browserPageId: 'page-1' + }, isActive: true } ]) }) + it('does not resurrect a stale workspace failure after the active page clears it', () => { + const staleError = { + code: -202, + description: 'ERR_CERT_AUTHORITY_INVALID', + validatedUrl: 'https://localhost:3443/' + } + const workspace = { ...makeBrowserWorkspace(), loadError: staleError } + const activePage = { + ...workspace, + id: 'page-1', + workspaceId: workspace.id, + loadError: null + } + const state = makeState({ + activeBrowserTabIdByWorktree: { 'wt-1': workspace.id }, + browserTabsByWorktree: { 'wt-1': [workspace] }, + browserPagesByWorkspace: { [workspace.id]: [activePage] } + }) + + expect(buildMobileSessionTabSnapshots(state)[0]?.tabs[0]).toMatchObject({ + type: 'browser', + loadError: null + }) + }) + it('publishes fallback browser tabs by workspace id when no unified tab exists', () => { const state = makeState({ activeBrowserTabIdByWorktree: { 'wt-1': 'browser-1' }, diff --git a/src/renderer/src/runtime/sync-runtime-graph.ts b/src/renderer/src/runtime/sync-runtime-graph.ts index b684e3fba9a..e735e130004 100644 --- a/src/renderer/src/runtime/sync-runtime-graph.ts +++ b/src/renderer/src/runtime/sync-runtime-graph.ts @@ -1492,6 +1492,12 @@ function buildMobileBrowserTab( loading: activePage?.loading ?? workspace.loading, canGoBack: activePage?.canGoBack ?? workspace.canGoBack, canGoForward: activePage?.canGoForward ?? workspace.canGoForward, + // Why: null means the active page successfully cleared its failure. Falling + // back through ?? would resurrect a stale workspace-level error. + loadError: activePage ? activePage.loadError : workspace.loadError, + certificateFailure: activePage + ? (state.browserCertificateFailuresByPageId[activePage.id] ?? null) + : null, color: unifiedTab?.color ?? null, isPinned: unifiedTab?.isPinned === true, isActive: unifiedTabId diff --git a/src/renderer/src/runtime/web-session-tabs-sync.test.ts b/src/renderer/src/runtime/web-session-tabs-sync.test.ts index d5eef6225ae..9d5f20f926f 100644 --- a/src/renderer/src/runtime/web-session-tabs-sync.test.ts +++ b/src/renderer/src/runtime/web-session-tabs-sync.test.ts @@ -64,6 +64,7 @@ function makeState(overrides: Partial = {}): WebSession activeWorktreeId: WT, agentStatusByPaneKey: {}, agentStatusEpoch: 0, + browserCertificateFailuresByPageId: {}, browserPagesByWorkspace: {}, browserTabsByWorktree: {}, groupsByWorktree: {}, @@ -2359,6 +2360,21 @@ describe('applyWebSessionTabsSnapshot', () => { loading: false, canGoBack: true, canGoForward: false, + loadError: { + code: -202, + description: 'ERR_CERT_AUTHORITY_INVALID', + validatedUrl: 'https://localhost:3443/' + }, + certificateFailure: { + challengeId: 'challenge-1', + browserPageId: 'host-browser-page', + errorCode: -202, + error: 'ERR_CERT_AUTHORITY_INVALID', + origin: 'https://localhost:3443', + displayHost: 'localhost:3443', + canProceed: true, + observedAt: 123 + }, color: '#3b82f6', isPinned: true, isActive: true @@ -2390,13 +2406,28 @@ describe('applyWebSessionTabsSnapshot', () => { worktreeId: WT, url: 'https://example.com/', title: 'Example Domain', - loading: false + loading: false, + loadError: { + code: -202, + description: 'ERR_CERT_AUTHORITY_INVALID', + validatedUrl: 'https://localhost:3443/' + } } ]) expect(patch.remoteBrowserPageHandlesByPageId?.['host-browser-page']).toEqual({ environmentId: ENV, remotePageId: 'host-browser-page' }) + expect(patch.browserCertificateFailuresByPageId?.['host-browser-page']).toEqual({ + challengeId: 'challenge-1', + browserPageId: 'host-browser-page', + errorCode: -202, + error: 'ERR_CERT_AUTHORITY_INVALID', + origin: 'https://localhost:3443', + displayHost: 'localhost:3443', + canProceed: true, + observedAt: 123 + }) expect(patch.unifiedTabsByWorktree?.[WT]).toEqual( expect.arrayContaining([ expect.objectContaining({ @@ -2888,6 +2919,18 @@ describe('applyWebSessionTabsSnapshot', () => { remoteBrowserPageHandlesByPageId: { [page.id]: { environmentId: ENV, remotePageId: 'host-browser-page' } }, + browserCertificateFailuresByPageId: { + [page.id]: { + challengeId: 'stale-challenge', + browserPageId: 'host-browser-page', + errorCode: -202, + error: 'ERR_CERT_AUTHORITY_INVALID', + origin: 'https://localhost:3443', + displayHost: 'localhost:3443', + canProceed: true, + observedAt: 100 + } + }, openFiles: [file], unifiedTabsByWorktree: { [WT]: existingTabs } }), @@ -2945,6 +2988,9 @@ describe('applyWebSessionTabsSnapshot', () => { }) ]) ) + // Why: older runtimes omit this transient field. Omission must clear an + // earlier challenge instead of leaving an unsafe action wired to stale RPC input. + expect(patch.browserCertificateFailuresByPageId).toEqual({}) }) it('uses local markdown preview file ids while preserving the host unified tab id', () => { diff --git a/src/renderer/src/runtime/web-session-tabs-sync.ts b/src/renderer/src/runtime/web-session-tabs-sync.ts index 54a94965767..41fbb2c8fe1 100644 --- a/src/renderer/src/runtime/web-session-tabs-sync.ts +++ b/src/renderer/src/runtime/web-session-tabs-sync.ts @@ -19,6 +19,7 @@ import type { RuntimeMobileSessionTerminalClientTab } from '../../../shared/runtime-types' import type { + BrowserCertificateFailure, BrowserPage, BrowserWorkspace, Tab, @@ -104,6 +105,7 @@ type MirroredTerminalTab = { type MirroredBrowserTab = { workspace: BrowserWorkspace page: BrowserPage + certificateFailure: BrowserCertificateFailure | null remotePageId: string unifiedTab: Tab hostTabId: string @@ -130,6 +132,7 @@ export type WebSessionTabsSyncState = Pick< | 'agentStatusByPaneKey' | 'agentStatusEpoch' | 'browserPagesByWorkspace' + | 'browserCertificateFailuresByPageId' | 'browserTabsByWorktree' | 'groupsByWorktree' | 'layoutByWorktree' @@ -951,7 +954,7 @@ function buildMirroredBrowserTabs( faviconUrl: existing?.page.faviconUrl ?? null, canGoBack: tab.canGoBack, canGoForward: tab.canGoForward, - loadError: null, + loadError: tab.loadError ?? null, createdAt, browserRuntimeEnvironmentId: environmentId, viewportPresetId: existing?.page.viewportPresetId ?? null @@ -975,6 +978,7 @@ function buildMirroredBrowserTabs( return { workspace, page, + certificateFailure: tab.certificateFailure ?? null, remotePageId: tab.browserPageId, unifiedTab: buildBrowserUnifiedTab(workspace, tab, existing?.unifiedTab ?? null, groupId), hostTabId: tab.id @@ -1478,6 +1482,29 @@ function browserPageEqual(a: BrowserPage, b: BrowserPage): boolean { ) } +function browserCertificateFailureEqual( + a: BrowserCertificateFailure | null | undefined, + b: BrowserCertificateFailure | null | undefined +): boolean { + const left = a ?? null + const right = b ?? null + if (left === right) { + return true + } + return Boolean( + left && + right && + left.challengeId === right.challengeId && + left.browserPageId === right.browserPageId && + left.errorCode === right.errorCode && + left.error === right.error && + left.origin === right.origin && + left.displayHost === right.displayHost && + left.canProceed === right.canProceed && + left.observedAt === right.observedAt + ) +} + function sameBrowserPages( a: readonly BrowserPage[] | undefined, b: readonly BrowserPage[] | null @@ -2135,6 +2162,7 @@ export function applyWebSessionTabsSnapshot( let nextBrowserPagesByWorkspace = state.browserPagesByWorkspace let nextRemoteBrowserPageHandlesByPageId = state.remoteBrowserPageHandlesByPageId + let nextBrowserCertificateFailuresByPageId = state.browserCertificateFailuresByPageId for (const removedWorkspaceId of removedBrowserWorkspaceIds) { const pages = nextBrowserPagesByWorkspace[removedWorkspaceId] ?? [] if (nextBrowserPagesByWorkspace[removedWorkspaceId]) { @@ -2145,6 +2173,13 @@ export function applyWebSessionTabsSnapshot( delete nextBrowserPagesByWorkspace[removedWorkspaceId] } for (const page of pages) { + if (nextBrowserCertificateFailuresByPageId[page.id]) { + nextBrowserCertificateFailuresByPageId = + nextBrowserCertificateFailuresByPageId === state.browserCertificateFailuresByPageId + ? { ...state.browserCertificateFailuresByPageId } + : nextBrowserCertificateFailuresByPageId + delete nextBrowserCertificateFailuresByPageId[page.id] + } if (nextRemoteBrowserPageHandlesByPageId[page.id]) { nextRemoteBrowserPageHandlesByPageId = nextRemoteBrowserPageHandlesByPageId === state.remoteBrowserPageHandlesByPageId @@ -2154,7 +2189,7 @@ export function applyWebSessionTabsSnapshot( } } } - for (const { page, remotePageId } of mirroredBrowserTabs) { + for (const { page, certificateFailure, remotePageId } of mirroredBrowserTabs) { const current = nextBrowserPagesByWorkspace[page.workspaceId] ?? [] if (!sameBrowserPages(current, [page])) { nextBrowserPagesByWorkspace = @@ -2177,6 +2212,22 @@ export function applyWebSessionTabsSnapshot( remotePageId } } + if ( + !browserCertificateFailureEqual( + nextBrowserCertificateFailuresByPageId[page.id], + certificateFailure + ) + ) { + nextBrowserCertificateFailuresByPageId = + nextBrowserCertificateFailuresByPageId === state.browserCertificateFailuresByPageId + ? { ...state.browserCertificateFailuresByPageId } + : nextBrowserCertificateFailuresByPageId + if (certificateFailure) { + nextBrowserCertificateFailuresByPageId[page.id] = certificateFailure + } else { + delete nextBrowserCertificateFailuresByPageId[page.id] + } + } } const nextTabsByWorktree = withWorktreeEntry( @@ -2383,6 +2434,9 @@ export function applyWebSessionTabsSnapshot( ...(nextRemoteBrowserPageHandlesByPageId !== state.remoteBrowserPageHandlesByPageId ? { remoteBrowserPageHandlesByPageId: nextRemoteBrowserPageHandlesByPageId } : {}), + ...(nextBrowserCertificateFailuresByPageId !== state.browserCertificateFailuresByPageId + ? { browserCertificateFailuresByPageId: nextBrowserCertificateFailuresByPageId } + : {}), ...(nextActiveTabIdByWorktree !== state.activeTabIdByWorktree ? { activeTabIdByWorktree: nextActiveTabIdByWorktree } : {}), diff --git a/src/renderer/src/store/slices/browser.test.ts b/src/renderer/src/store/slices/browser.test.ts index 61a534c6194..c6b441fbb4c 100644 --- a/src/renderer/src/store/slices/browser.test.ts +++ b/src/renderer/src/store/slices/browser.test.ts @@ -180,6 +180,39 @@ describe('createBrowserSlice annotations', () => { expect(store.getState().browserAnnotationsByPageId[pageId]).toBeUndefined() }) + it('keeps certificate challenges transient across navigation, success, and close', () => { + const store = createTestStore() + const tab = store.getState().createBrowserTab('wt-1', 'https://localhost:3443/') + const pageId = tab.activePageId + if (!pageId) { + throw new Error('Expected a new browser page') + } + const failure = { + challengeId: 'challenge-1', + browserPageId: pageId, + errorCode: -202, + error: 'ERR_CERT_AUTHORITY_INVALID', + origin: 'https://localhost:3443', + displayHost: 'localhost:3443', + canProceed: true, + observedAt: 123 + } + + store.getState().setBrowserPageCertificateFailure(pageId, failure) + expect(store.getState().browserCertificateFailuresByPageId[pageId]).toEqual(failure) + + store.getState().setBrowserPageUrl(pageId, 'https://localhost:3443/next') + expect(store.getState().browserCertificateFailuresByPageId[pageId]).toBeUndefined() + + store.getState().setBrowserPageCertificateFailure(pageId, failure) + store.getState().updateBrowserPageState(pageId, { loadError: null }) + expect(store.getState().browserCertificateFailuresByPageId[pageId]).toBeUndefined() + + store.getState().setBrowserPageCertificateFailure(pageId, failure) + store.getState().closeBrowserTab(tab.id) + expect(store.getState().browserCertificateFailuresByPageId[pageId]).toBeUndefined() + }) + it('creates inactive browser unified tabs without stealing the visible tab', () => { const store = createTestStore() diff --git a/src/renderer/src/store/slices/browser.ts b/src/renderer/src/store/slices/browser.ts index 10f955275bc..ea4d2e0ebb3 100644 --- a/src/renderer/src/store/slices/browser.ts +++ b/src/renderer/src/store/slices/browser.ts @@ -4,6 +4,7 @@ import type { AppState } from '../types' import type { BrowserCookieImportResult, BrowserCookieImportSummary, + BrowserCertificateFailure, BrowserHistoryEntry, BrowserLoadError, BrowserPage, @@ -117,6 +118,7 @@ export type RemoteBrowserPageHandle = { export type BrowserSlice = { browserTabsByWorktree: Record browserPagesByWorkspace: Record + browserCertificateFailuresByPageId: Record browserAnnotationsByPageId: Record remoteBrowserPageHandlesByPageId: Record activeBrowserTabId: string | null @@ -159,6 +161,10 @@ export type BrowserSlice = { consumeAddressBarFocusRequest: (pageId: string) => boolean updateBrowserTabPageState: (pageId: string, updates: BrowserTabPageState) => void updateBrowserPageState: (pageId: string, updates: BrowserTabPageState) => void + setBrowserPageCertificateFailure: ( + pageId: string, + failure: BrowserCertificateFailure | null + ) => void setBrowserTabUrl: (pageId: string, url: string) => void setBrowserPageUrl: (pageId: string, url: string) => void setRemoteBrowserPageHandle: (pageId: string, handle: RemoteBrowserPageHandle) => void @@ -473,6 +479,7 @@ function findPage( export const createBrowserSlice: StateCreator = (set, get) => ({ browserTabsByWorktree: {}, browserPagesByWorkspace: {}, + browserCertificateFailuresByPageId: {}, browserAnnotationsByPageId: {}, remoteBrowserPageHandlesByPageId: {}, activeBrowserTabId: null, @@ -675,8 +682,12 @@ export const createBrowserSlice: StateCreator = const nextBrowserPagesByWorkspace = { ...s.browserPagesByWorkspace } delete nextBrowserPagesByWorkspace[tabId] const nextBrowserAnnotationsByPageId = { ...s.browserAnnotationsByPageId } + const nextBrowserCertificateFailuresByPageId = { + ...s.browserCertificateFailuresByPageId + } for (const page of closedPages) { delete nextBrowserAnnotationsByPageId[page.id] + delete nextBrowserCertificateFailuresByPageId[page.id] } remotePagesToClose = closedPages.flatMap((page) => { const handle = s.remoteBrowserPageHandlesByPageId[page.id] @@ -766,6 +777,7 @@ export const createBrowserSlice: StateCreator = recentlyClosedTabKindsByWorktree: nextRecentlyClosedTabKindsByWorktree, recentlyClosedBrowserPagesByWorkspace: nextRecentlyClosedBrowserPagesByWorkspace, remoteBrowserPageHandlesByPageId: nextRemoteBrowserPageHandlesByPageId, + browserCertificateFailuresByPageId: nextBrowserCertificateFailuresByPageId, browserAnnotationsByPageId: nextBrowserAnnotationsByPageId } }) @@ -1040,6 +1052,10 @@ export const createBrowserSlice: StateCreator = delete nextRemoteBrowserPageHandlesByPageId[pageId] const nextBrowserAnnotationsByPageId = { ...s.browserAnnotationsByPageId } delete nextBrowserAnnotationsByPageId[pageId] + const nextBrowserCertificateFailuresByPageId = { + ...s.browserCertificateFailuresByPageId + } + delete nextBrowserCertificateFailuresByPageId[pageId] return { browserPagesByWorkspace: { @@ -1072,6 +1088,7 @@ export const createBrowserSlice: StateCreator = ) ), remoteBrowserPageHandlesByPageId: nextRemoteBrowserPageHandlesByPageId, + browserCertificateFailuresByPageId: nextBrowserCertificateFailuresByPageId, browserAnnotationsByPageId: nextBrowserAnnotationsByPageId } }) @@ -1367,6 +1384,32 @@ export const createBrowserSlice: StateCreator = } return nextState }) + if (updates.loadError === null) { + get().setBrowserPageCertificateFailure(pageId, null) + } + }, + + setBrowserPageCertificateFailure: (pageId, failure) => { + set((s) => { + const current = s.browserCertificateFailuresByPageId[pageId] + if (failure === null) { + if (!current) { + return s + } + const nextFailures = { ...s.browserCertificateFailuresByPageId } + delete nextFailures[pageId] + return { browserCertificateFailuresByPageId: nextFailures } + } + if (!findPage(s.browserPagesByWorkspace, pageId) || current === failure) { + return s + } + return { + browserCertificateFailuresByPageId: { + ...s.browserCertificateFailuresByPageId, + [pageId]: failure + } + } + }) }, setBrowserTabUrl: (pageId, url) => get().setBrowserPageUrl(pageId, url), @@ -1425,6 +1468,7 @@ export const createBrowserSlice: StateCreator = : {}) } }) + get().setBrowserPageCertificateFailure(pageId, null) }, setRemoteBrowserPageHandle: (pageId, handle) => { @@ -1688,6 +1732,7 @@ export const createBrowserSlice: StateCreator = activeTabTypeByWorktree: nextActiveTabTypeByWorktree, activeTabType, remoteBrowserPageHandlesByPageId: {}, + browserCertificateFailuresByPageId: {}, browserAnnotationsByPageId: {}, browserUrlHistory: normalizeBrowserHistoryEntries(session.browserUrlHistory ?? []) } diff --git a/src/renderer/src/web/web-preload-api.ts b/src/renderer/src/web/web-preload-api.ts index d6f6d6e5830..29268cee148 100644 --- a/src/renderer/src/web/web-preload-api.ts +++ b/src/renderer/src/web/web-preload-api.ts @@ -1993,12 +1993,14 @@ function createGitApi(): NonNullable['git']> { function createBrowserApi(): NonNullable['browser']> { return { - registerGuest: () => Promise.resolve(), + registerGuest: () => Promise.resolve(false), unregisterGuest: () => Promise.resolve(), openDevTools: () => Promise.resolve(false), setViewportOverride: () => Promise.resolve(false), setAnnotationViewportBridge: () => Promise.resolve(false), onGuestLoadFailed: () => noopUnsubscribe, + onCertificateFailureChanged: () => noopUnsubscribe, + proceedCertificate: () => Promise.resolve({ ok: false, reason: 'missing' }), onPermissionDenied: () => noopUnsubscribe, onPopup: () => noopUnsubscribe, onDownloadRequested: () => noopUnsubscribe, diff --git a/src/shared/browser-certificate-errors.ts b/src/shared/browser-certificate-errors.ts new file mode 100644 index 00000000000..9f1eba87dee --- /dev/null +++ b/src/shared/browser-certificate-errors.ts @@ -0,0 +1,9 @@ +// Why: use Chromium's exact certificate codes so unrelated network failures +// never receive certificate-specific recovery copy. +const CHROMIUM_CERTIFICATE_ERROR_CODES = new Set([ + -200, -201, -202, -203, -204, -205, -206, -207, -208, -210, -211, -212, -213, -214, -217, -219 +]) + +export function isChromiumCertificateErrorCode(code: number): boolean { + return CHROMIUM_CERTIFICATE_ERROR_CODES.has(code) +} diff --git a/src/shared/browser-url.test.ts b/src/shared/browser-url.test.ts index ce5487be0c0..4a246355fea 100644 --- a/src/shared/browser-url.test.ts +++ b/src/shared/browser-url.test.ts @@ -2,10 +2,15 @@ import { describe, expect, it } from 'vitest' import { ORCA_BROWSER_BLANK_URL } from './constants' import { buildSearchUrl, + classifySchemeLessLocalDevAddress, + isEligibleLocalCertificateHost, normalizeKagiSessionLink, normalizeBrowserNavigationUrl, normalizeExternalBrowserUrl, - redactKagiSessionToken + redactKagiSessionToken, + resolveRemoteFailureExternalUrl, + toSecureCertificateEndpoint, + toHttpsRecoveryUrl } from './browser-url' describe('browser-url helpers', () => { @@ -20,6 +25,98 @@ describe('browser-url helpers', () => { ) }) + it('keeps the legacy scheme-less local-dev classifier broader than certificate eligibility', () => { + for (const input of [ + 'localhost:3000/path', + '127.0.0.1:5173', + '0.0.0.0:8080', + '[::1]:3000', + '[2001:db8::1]:3000' + ]) { + expect(classifySchemeLessLocalDevAddress(input), input).not.toBeNull() + } + expect(classifySchemeLessLocalDevAddress('app.localhost:3000')).toBeNull() + expect(isEligibleLocalCertificateHost('0.0.0.0')).toBe(false) + expect(isEligibleLocalCertificateHost('[2001:db8::1]')).toBe(false) + }) + + it('recognizes only canonical loopback certificate hosts', () => { + for (const hostname of [ + 'localhost', + 'LOCALHOST.', + 'app.localhost', + 'deep.app.localhost.', + '127.0.0.1', + '127.255.255.255', + '::1', + '[::1]' + ]) { + expect(isEligibleLocalCertificateHost(hostname), hostname).toBe(true) + } + for (const hostname of [ + '0.0.0.0', + '::', + '[2001:db8::1]', + '192.168.1.1', + 'localhost.example.com', + 'notlocalhost', + '.localhost', + '-bad.localhost', + 'bad-.localhost', + '127.0.0.999', + '127.00.0.1' + ]) { + expect(isEligibleLocalCertificateHost(hostname), hostname).toBe(false) + } + }) + + it('constructs HTTPS recovery URLs without changing the rest of the address', () => { + expect(toHttpsRecoveryUrl('http://localhost:3000/path?q=1#preview')).toBe( + 'https://localhost:3000/path?q=1#preview' + ) + expect(toHttpsRecoveryUrl('http://user:pass@127.0.0.2:8080/')).toBe( + 'https://user:pass@127.0.0.2:8080/' + ) + expect(toHttpsRecoveryUrl('http://localhost:80/path')).toBe('https://localhost/path') + expect(toHttpsRecoveryUrl('https://localhost:3000/')).toBeNull() + expect(toHttpsRecoveryUrl('http://0.0.0.0:3000/')).toBeNull() + expect(toHttpsRecoveryUrl('http://example.com/')).toBeNull() + expect(toHttpsRecoveryUrl('not a url')).toBeNull() + }) + + it('canonicalizes secure certificate endpoints without path or credential data', () => { + expect(toSecureCertificateEndpoint('https://User:secret@LOCALHOST.:443/path?q=1')).toBe( + 'https://localhost:443' + ) + expect(toSecureCertificateEndpoint('wss://localhost:3000/socket')).toBe( + 'https://localhost:3000' + ) + expect(toSecureCertificateEndpoint('https://[::1]/')).toBe('https://[::1]:443') + expect(toSecureCertificateEndpoint('http://localhost:3000/')).toBeNull() + expect(toSecureCertificateEndpoint('not a url')).toBeNull() + }) + + it('offers Open Externally for a remote failure only when the URL is desktop-reachable', () => { + // Loopback / wildcard hosts are unreachable from the desktop system browser. + expect(resolveRemoteFailureExternalUrl('https://localhost:3000/')).toBeNull() + expect(resolveRemoteFailureExternalUrl('https://127.0.0.1:3000/')).toBeNull() + expect(resolveRemoteFailureExternalUrl('https://127.0.0.9:3000/')).toBeNull() + expect(resolveRemoteFailureExternalUrl('https://[::1]:3000/')).toBeNull() + expect(resolveRemoteFailureExternalUrl('https://app.localhost:3000/')).toBeNull() + expect(resolveRemoteFailureExternalUrl('http://0.0.0.0:3000/')).toBeNull() + expect(resolveRemoteFailureExternalUrl('http://[::]:3000/')).toBeNull() + // Public hosts are reachable, so the action is offered. + expect(resolveRemoteFailureExternalUrl('https://example.com/app')).toBe( + 'https://example.com/app' + ) + expect(resolveRemoteFailureExternalUrl('http://example.com:8080/x')).toBe( + 'http://example.com:8080/x' + ) + // Non-web schemes and garbage never become an external target. + expect(resolveRemoteFailureExternalUrl('file:///etc/passwd')).toBeNull() + expect(resolveRemoteFailureExternalUrl('not a url')).toBeNull() + }) + it('keeps normal web URLs and blank tabs in the allowed set', () => { expect(normalizeBrowserNavigationUrl('https://example.com')).toBe('https://example.com/') expect(normalizeBrowserNavigationUrl('')).toBe(ORCA_BROWSER_BLANK_URL) diff --git a/src/shared/browser-url.ts b/src/shared/browser-url.ts index 71e980db2f6..7eb2ca6ebe1 100644 --- a/src/shared/browser-url.ts +++ b/src/shared/browser-url.ts @@ -34,6 +34,111 @@ const SEARCH_ENGINE_URLS: Record = { export const DEFAULT_SEARCH_ENGINE: SearchEngine = 'google' +export function classifySchemeLessLocalDevAddress(rawInput: string): URL | null { + const trimmed = rawInput.trim() + if (!LOCAL_ADDRESS_PATTERN.test(trimmed)) { + return null + } + try { + return new URL(`http://${trimmed}`) + } catch { + return null + } +} + +function normalizeCertificateHostname(hostname: string): string { + const lower = hostname.trim().toLowerCase() + const unbracketed = lower.startsWith('[') && lower.endsWith(']') ? lower.slice(1, -1) : lower + return unbracketed.endsWith('.') ? unbracketed.slice(0, -1) : unbracketed +} + +function isValidDnsName(name: string): boolean { + if (name.length === 0 || name.length > 253) { + return false + } + return name + .split('.') + .every( + (label) => + label.length > 0 && label.length <= 63 && /^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/.test(label) + ) +} + +function isIpv4Loopback(hostname: string): boolean { + const octets = hostname.split('.') + if (octets.length !== 4 || octets.some((octet) => !/^\d{1,3}$/.test(octet))) { + return false + } + const values = octets.map(Number) + return ( + values[0] === 127 && + values.every((value, index) => value >= 0 && value <= 255 && octets[index] === String(value)) + ) +} + +export function isEligibleLocalCertificateHost(hostname: string): boolean { + const normalized = normalizeCertificateHostname(hostname) + if (normalized === '::1' || isIpv4Loopback(normalized)) { + return true + } + if (!isValidDnsName(normalized)) { + return false + } + return normalized === 'localhost' || normalized.endsWith('.localhost') +} + +function isWildcardBindHost(hostname: string): boolean { + const normalized = normalizeCertificateHostname(hostname) + return normalized === '0.0.0.0' || normalized === '::' +} + +export function toHttpsRecoveryUrl(rawUrl: string): string | null { + try { + const parsed = new URL(rawUrl) + if (parsed.protocol !== 'http:' || !isEligibleLocalCertificateHost(parsed.hostname)) { + return null + } + parsed.protocol = 'https:' + return parsed.toString() + } catch { + return null + } +} + +export function toSecureCertificateEndpoint(rawUrl: string): string | null { + try { + const parsed = new URL(rawUrl) + if (parsed.protocol !== 'https:' && parsed.protocol !== 'wss:') { + return null + } + const normalizedHostname = normalizeCertificateHostname(parsed.hostname) + if (!normalizedHostname) { + return null + } + const endpointHost = normalizedHostname.includes(':') + ? `[${normalizedHostname}]` + : normalizedHostname + return `https://${endpointHost}:${parsed.port || '443'}` + } catch { + return null + } +} + +// Why: a remote-owned browser page's loopback URL is unreachable from the +// desktop system browser, so Open Externally is offered only for publicly +// reachable (non-loopback) failure URLs. +export function resolveRemoteFailureExternalUrl(rawUrl: string): string | null { + try { + const parsed = new URL(rawUrl) + if (isWildcardBindHost(parsed.hostname) || isEligibleLocalCertificateHost(parsed.hostname)) { + return null + } + } catch { + return null + } + return normalizeExternalBrowserUrl(rawUrl) +} + export function normalizeKagiSessionLink(rawLink: string): string | null { const trimmed = rawLink.trim() if (!trimmed) { @@ -163,12 +268,9 @@ export function normalizeBrowserNavigationUrl( return ORCA_BROWSER_BLANK_URL } - if (LOCAL_ADDRESS_PATTERN.test(trimmed)) { - try { - return new URL(`http://${trimmed}`).toString() - } catch { - return null - } + const localDevAddress = classifySchemeLessLocalDevAddress(trimmed) + if (localDevAddress) { + return localDevAddress.toString() } if (WINDOWS_UNC_PATH_PATTERN.test(trimmed)) { diff --git a/src/shared/protocol-version.ts b/src/shared/protocol-version.ts index 89e4303014c..f6242c93e99 100644 --- a/src/shared/protocol-version.ts +++ b/src/shared/protocol-version.ts @@ -38,6 +38,7 @@ export const AI_VAULT_RUNTIME_CAPABILITY = 'aiVault.v1' as const // offscreen backend). Advertised only when that backend is actually available, so // clients never fall back to a local desktop browser tab for a remote-owned page. export const BROWSER_HEADLESS_RUNTIME_CAPABILITY = 'browser.headless.v1' as const +export const BROWSER_CERTIFICATE_TRUST_RUNTIME_CAPABILITY = 'browser.certificate-trust.v1' as const // Why: hosts without this strip terminal.send's inputKind (zod object drops // unknown keys), so a mobile xterm query reply would land as ordinary // floor-taking input. Mobile must not forward replies unless advertised. diff --git a/src/shared/runtime-types.ts b/src/shared/runtime-types.ts index 774114b6d75..953dae57909 100644 --- a/src/shared/runtime-types.ts +++ b/src/shared/runtime-types.ts @@ -8,6 +8,8 @@ import type { import type { BaseRefSearchResult, BrowserCookieImportResult, + BrowserCertificateFailure, + BrowserLoadError, BrowserSessionProfile, BrowserSessionProfileSource, CreateWorktreeResult, @@ -206,6 +208,8 @@ export type RuntimeMobileSessionBrowserTab = { loading: boolean canGoBack: boolean canGoForward: boolean + loadError?: BrowserLoadError | null + certificateFailure?: BrowserCertificateFailure | null color?: string | null isPinned?: boolean isActive: boolean @@ -818,6 +822,11 @@ export type BrowserTabInfo = { url: string title: string active: boolean + // Why: a failed load leaves getURL() at chrome-error://; surface the structured + // error so an agent driving the browser can tell a bypassable certificate + // failure from an ordinary network error the way the UI can. + loadError?: BrowserLoadError | null + certificateFailure?: BrowserCertificateFailure | null worktreeId?: string | null profileId?: string | null profileLabel?: string | null diff --git a/src/shared/types.ts b/src/shared/types.ts index 4d4301a0b6f..ff46520c44d 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -889,6 +889,28 @@ export type BrowserLoadError = { validatedUrl: string } +export type BrowserCertificateFailure = { + challengeId: string + browserPageId: string + errorCode: number | null + error: string + origin: string + displayHost: string + canProceed: boolean + observedAt: number +} + +export type BrowserCertificateProceedFailureReason = + | 'expired' + | 'changed' + | 'ineligible' + | 'missing' + | 'navigated' + +export type BrowserCertificateProceedResult = + | { ok: true } + | { ok: false; reason: BrowserCertificateProceedFailureReason } + // Why: BrowserPage persists the active viewport preset so CDP emulation can be // reapplied on reload/navigation without the user re-picking from the toolbar. export type BrowserViewportPresetId = diff --git a/tests/e2e/browser-local-https-certificate-trust.spec.ts b/tests/e2e/browser-local-https-certificate-trust.spec.ts new file mode 100644 index 00000000000..c82ec75f959 --- /dev/null +++ b/tests/e2e/browser-local-https-certificate-trust.spec.ts @@ -0,0 +1,199 @@ +import type { Page } from '@stablyai/playwright-test' + +import { expect, test } from './helpers/orca-app' +import { + ensureTerminalVisible, + getActiveWorktreeId, + waitForActiveWorktree, + waitForSessionReady +} from './helpers/store' +import { startLocalHttpProbeServer, startLocalHttpsServer } from './helpers/local-https-test-server' + +type CreatedBrowserTab = { + id: string + pageId: string +} + +async function createBrowserTab( + page: Page, + worktreeId: string, + url: string +): Promise { + const tab = await page.evaluate( + ({ targetWorktreeId, targetUrl }) => { + const state = window.__store?.getState() + if (!state) { + return null + } + const browserTab = state.createBrowserTab(targetWorktreeId, targetUrl, { + title: 'Local TLS', + activate: true + }) + return { id: browserTab.id, pageId: browserTab.activePageId ?? null } + }, + { targetWorktreeId: worktreeId, targetUrl: url } + ) + if (!tab?.pageId) { + throw new Error('Failed to create local TLS browser page') + } + return { id: tab.id, pageId: tab.pageId } +} + +async function switchToBrowserTab(page: Page, worktreeId: string, browserTabId: string) { + await page.evaluate( + ({ targetWorktreeId, targetBrowserTabId }) => { + const state = window.__store?.getState() + if (!state) { + return + } + if ( + !(state.browserTabsByWorktree[targetWorktreeId] ?? []).some( + (tab) => tab.id === targetBrowserTabId + ) + ) { + return + } + state.setActiveBrowserTab(targetBrowserTabId) + state.setActiveTabType('browser') + }, + { targetWorktreeId: worktreeId, targetBrowserTabId: browserTabId } + ) +} + +function browserSlot(page: Page, pageId: string) { + return page.locator(`[data-browser-overlay-tab-id="${pageId}"]`) +} + +async function readBrowserHeading(page: Page, browserTabId: string): Promise { + return page.evaluate(async (targetBrowserTabId) => { + const slot = [...document.querySelectorAll('[data-browser-overlay-tab-id]')].find( + (candidate) => candidate.getAttribute('data-browser-overlay-tab-id') === targetBrowserTabId + ) + const webview = slot?.querySelector('webview') as Electron.WebviewTag | null + if (!webview) { + return null + } + try { + return await webview.executeJavaScript('document.querySelector("h1")?.textContent ?? null') + } catch { + return null + } + }, browserTabId) +} + +async function readBrowserState( + page: Page, + browserTabId: string, + stateName: '__localTlsState' | '__siblingTlsProbe' +): Promise | null> { + return page.evaluate( + async ({ targetBrowserTabId, targetStateName }) => { + const slot = [...document.querySelectorAll('[data-browser-overlay-tab-id]')].find( + (candidate) => candidate.getAttribute('data-browser-overlay-tab-id') === targetBrowserTabId + ) + const webview = slot?.querySelector('webview') as Electron.WebviewTag | null + if (!webview) { + return null + } + try { + return await webview.executeJavaScript(`window.${targetStateName} ?? null`) + } catch { + return null + } + }, + { targetBrowserTabId: browserTabId, targetStateName: stateName } + ) +} + +async function reloadBrowserGuest(page: Page, browserTabId: string): Promise { + await page.evaluate((targetBrowserTabId) => { + const slot = [...document.querySelectorAll('[data-browser-overlay-tab-id]')].find( + (candidate) => candidate.getAttribute('data-browser-overlay-tab-id') === targetBrowserTabId + ) + const webview = slot?.querySelector('webview') as Electron.WebviewTag | null + if (!webview) { + throw new Error(`Missing webview for browser tab ${targetBrowserTabId}`) + } + webview.reload() + }, browserTabId) +} + +test.describe('local HTTPS certificate trust', () => { + test.beforeEach(async ({ orcaPage }) => { + await waitForSessionReady(orcaPage) + await waitForActiveWorktree(orcaPage) + await ensureTerminalVisible(orcaPage) + }) + + test('approves one exact local certificate endpoint without trusting sibling tabs or ports', async ({ + orcaPage + }) => { + const firstServer = await startLocalHttpsServer() + const secondPortServer = await startLocalHttpsServer() + const siblingProbeServer = await startLocalHttpProbeServer(firstServer) + try { + const worktreeId = (await getActiveWorktreeId(orcaPage))! + const firstTab = await createBrowserTab(orcaPage, worktreeId, firstServer.schemeLessUrl) + const firstSlot = browserSlot(orcaPage, firstTab.id) + + await expect(firstSlot.getByRole('button', { name: 'Try HTTPS' })).toBeVisible() + await firstSlot.getByRole('button', { name: 'Try HTTPS' }).click() + await expect( + firstSlot.getByRole('heading', { name: "Connection isn't secure" }) + ).toBeVisible() + // The certificate-failure branch keeps its safe recovery actions and the + // certificate-specific hint, but never the local-server connectivity hint. + await expect(firstSlot.getByRole('button', { name: 'Copy Address' })).toBeVisible() + await expect(firstSlot.getByRole('button', { name: 'Retry' })).toBeVisible() + await expect(firstSlot.getByText(/use a trusted local certificate/i)).toBeVisible() + await expect(firstSlot.getByText(/make sure the server is running/i)).toHaveCount(0) + await firstSlot.getByRole('button', { name: 'Proceed Anyway (Unsafe)' }).click() + await expect + .poll(() => readBrowserHeading(orcaPage, firstTab.id), { timeout: 10_000 }) + .toBe('Local HTTPS request 1') + await expect + .poll(() => readBrowserState(orcaPage, firstTab.id, '__localTlsState')) + .toEqual({ asset: true, webSocket: true }) + expect(firstServer.assetRequestCount()).toBe(1) + expect(firstServer.webSocketConnectionCount()).toBe(1) + + const secondTab = await createBrowserTab(orcaPage, worktreeId, firstServer.secureUrl) + const secondSlot = browserSlot(orcaPage, secondTab.id) + await expect( + secondSlot.getByRole('heading', { name: "Connection isn't secure" }) + ).toBeVisible() + // Why: approval is scoped to the first guest WebContents, not its shared + // profile partition, so this sibling still requires an explicit decision. + await expect( + secondSlot.getByRole('button', { name: 'Proceed Anyway (Unsafe)' }) + ).toBeVisible() + + const probeTab = await createBrowserTab(orcaPage, worktreeId, siblingProbeServer.url) + await expect + .poll(() => readBrowserState(orcaPage, probeTab.id, '__siblingTlsProbe')) + .toEqual({ asset: 'blocked', webSocket: 'blocked' }) + expect(firstServer.assetRequestCount()).toBe(1) + expect(firstServer.webSocketConnectionCount()).toBe(1) + + await switchToBrowserTab(orcaPage, worktreeId, firstTab.id) + await reloadBrowserGuest(orcaPage, firstTab.id) + await expect.poll(firstServer.documentRequestCount, { timeout: 10_000 }).toBe(2) + await expect + .poll(() => readBrowserHeading(orcaPage, firstTab.id), { timeout: 10_000 }) + .toBe('Local HTTPS request 2') + await expect.poll(firstServer.assetRequestCount).toBe(2) + await expect.poll(firstServer.webSocketConnectionCount).toBe(2) + + const firstAddressBar = firstSlot.locator('[data-orca-browser-address-bar="true"]') + await firstAddressBar.fill(secondPortServer.secureUrl) + await firstAddressBar.press('Enter') + await expect( + firstSlot.getByRole('heading', { name: "Connection isn't secure" }) + ).toBeVisible() + await expect(firstSlot.getByRole('button', { name: 'Proceed Anyway (Unsafe)' })).toBeVisible() + await expect.poll(secondPortServer.documentRequestCount).toBe(0) + } finally { + await Promise.all([firstServer.close(), secondPortServer.close(), siblingProbeServer.close()]) + } + }) +}) diff --git a/tests/e2e/helpers/local-https-test-certificate.ts b/tests/e2e/helpers/local-https-test-certificate.ts new file mode 100644 index 00000000000..3fafc61bcfe --- /dev/null +++ b/tests/e2e/helpers/local-https-test-certificate.ts @@ -0,0 +1,50 @@ +// Static test-only identity with SANs for localhost and 127.0.0.1. Keeping the +// fixture in-repo avoids an OpenSSL dependency on Windows E2E runners. +export const LOCAL_HTTPS_TEST_PRIVATE_KEY = `-----BEGIN PRIVATE KEY----- +MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQCjkYboq/BDMxGh +0jYeeAEnhH466vJO2NTIsDAaqhVDxXSWqgcqPHhAXS3I4CCzBuTBKMCfZnFLjOf6 +/+8mGhMsWUmgkchZ23fo5kszbgslx+bu0NAT14OAuD0E5rNEJwGA2W7i3a3k1ULX +TSUpE9W1dxQLQzLSmXIKYST1Sadi3/qtuikcQW1E9Lyb1guf3gvm+jHSzUiM33ge +JX6zNkd3fX+uc2oNkm+tFo7Ol52A++cSIqxB8MCqrfUNhISyzu4Ozne9bsCoWdoW +4aLX+XVVRw9Y8nU9cIvt88H1a9oChrG+5oXU166pAdKZvAUV65fi/y3w3ldgKu5I +Xk+RJ+1xAgMBAAECggEAExg15DNPOd8MNcR7FCE8/EKhtEnRZd427+MuiGxWzWmv +d7erXLVAsf3WrpKoipG5UmnKG8mbG/9b5O+r+LoWRyj4uQWPupqt47q/qGY2J6/P +kA1BHzHbXINVfz0ZzBDUInkvkk0f49z3/7eWKRaTTgrzxHFQrWhjmV3YEUjrARYW +DtlAJiCuBW2Mrnl866kLwzH3KHGFs4bPYIyMr3LYDYVDlHEdGuseI1OclGXbDcyF +z0Es9Z1u+W6ttnamvanhS7MskWCMOddtXKpaiuDQqo1r3xbhSHCA+7cs7J0cQ1FX +cq5VZ2/EMyI++U0/0sW0aWnUNKmZcGNwaDschejI6QKBgQDPfsyZ4o8urLEwPciB +PnfHxWtB/9IhqKwUHoUg5rPtYsyQBoWqAFBfS68FP91yv5KH0Y1sQ3SbN11+1d2U +uk0gQaV9TqLa51/8Za6C8Sig7UdwArEmuEmIJhj0Jux6QxF6th9Dpnrd4Bxtm42T +lnEMSvSB3vbyDk1Q+WJKblpJWQKBgQDJzgBJDGuj7LJrMaGqLjRshOoCyYbBjVsO +3GX24Vm1eO9SSOEpucoqf7hswnKx+/h1GPCxTife9/qobgGV64L7CMSrQviIC39l +GlUGyh1onGEZB/0xLjswuocRBU9qX88zRwpanqctxMtxD/36k2IP6ru4acvrryKD +lWla01Sp2QKBgGkuKH7VFqmdRpBisTG6vbMZgt5I1HbVbq0gL3HXIFv0Gifj9nuP +fy5fShAKKLITJC8O7XZ01zYbIZy6woCy04fHXyEe7HS0lrZ1wLmFj4fL38uKwcwT +3MpULZAN7w+m0cR3b2+2g0/XW/G/yUuIFjQaBsmSgXGACHdEgyuhtsi5AoGASLKu +RaJ00Gu/ZoBNpdnZRtKm3nQs2GMMz5C0JrjNsWMsi673dimY27CBBqUR3m5P9hcS +9jyafmdE5BIk/hYGbFqfRrbsg03pCcnvoW+EIqBbFkJbgrEN36MCby5DiqWTJfzM +jRKkVQeU5lkFfJRFekhscaWjMXc47sAPYQnKcRkCgYEAsHhU/aUtLUnSDgqrmRoC +VBxeCBa3/pMlBuiiRRMKvjMp+h0eGmgP0sP549d+PGBjkAu7Bp1szWUuB19OcULy +c33XAFdeZScLL1E3eMRVX4LwfJ5ZlUBrd3fv5R2X9D0TrAcHaGEVVHhkwRlhi69W +AcJ+zYua3ZDxmpZtCgGomOA= +-----END PRIVATE KEY-----` + +export const LOCAL_HTTPS_TEST_CERTIFICATE = `-----BEGIN CERTIFICATE----- +MIIDJTCCAg2gAwIBAgIUIw41Eu7j9W1oTBKtoStG2UbRdUIwDQYJKoZIhvcNAQEL +BQAwFDESMBAGA1UEAwwJbG9jYWxob3N0MB4XDTI2MDcxMzA2MzAxN1oXDTM2MDcx +MDA2MzAxN1owFDESMBAGA1UEAwwJbG9jYWxob3N0MIIBIjANBgkqhkiG9w0BAQEF +AAOCAQ8AMIIBCgKCAQEAo5GG6KvwQzMRodI2HngBJ4R+OuryTtjUyLAwGqoVQ8V0 +lqoHKjx4QF0tyOAgswbkwSjAn2ZxS4zn+v/vJhoTLFlJoJHIWdt36OZLM24LJcfm +7tDQE9eDgLg9BOazRCcBgNlu4t2t5NVC100lKRPVtXcUC0My0plyCmEk9UmnYt/6 +rbopHEFtRPS8m9YLn94L5vox0s1IjN94HiV+szZHd31/rnNqDZJvrRaOzpedgPvn +EiKsQfDAqq31DYSEss7uDs53vW7AqFnaFuGi1/l1VUcPWPJ1PXCL7fPB9WvaAoax +vuaF1NeuqQHSmbwFFeuX4v8t8N5XYCruSF5PkSftcQIDAQABo28wbTAdBgNVHQ4E +FgQUCmY8mF92kNqEbWlJ15z7Y1qq8wcwHwYDVR0jBBgwFoAUCmY8mF92kNqEbWlJ +15z7Y1qq8wcwDwYDVR0TAQH/BAUwAwEB/zAaBgNVHREEEzARgglsb2NhbGhvc3SH +BH8AAAEwDQYJKoZIhvcNAQELBQADggEBACidMu/gBoJozW/HwWzU924fHndhnZRV +nhzR1JWB1/7DaeC1ePJYs6eRKe+A7mV1qxPXDMpMqzv3KAo4WirDW8y4rsM/sp5w +C2qTGbo+nF6u4tX6p9RQnN4HPeJfGXOtOfkyXK7M7C7O+Rzo3RdJmZZk8GBWe0+q +vYIIvbLmC4zRFLL2Mp8fPlW7VeQWl8u+2nXQrC/0oOGN7cXjtsj8Dcu6939srMW/ +6KiypTji5Idkn7HK2qq4Obbzbet8euWabhMZ3N5+tAcWHvJoNy6wGQlBZmZaPbQD +6LnMrP0J7dmEcShy5MXhNp6BFQ9jkhGVkIxk3fRtHFO9vDPYbdo0Pho= +-----END CERTIFICATE-----` diff --git a/tests/e2e/helpers/local-https-test-server.ts b/tests/e2e/helpers/local-https-test-server.ts new file mode 100644 index 00000000000..b2c99fa4ea1 --- /dev/null +++ b/tests/e2e/helpers/local-https-test-server.ts @@ -0,0 +1,162 @@ +import { createServer as createHttpServer, type Server } from 'node:http' +import { createServer as createHttpsServer } from 'node:https' +import type { AddressInfo } from 'node:net' +import { WebSocketServer } from 'ws' + +import { + LOCAL_HTTPS_TEST_CERTIFICATE, + LOCAL_HTTPS_TEST_PRIVATE_KEY +} from './local-https-test-certificate' + +export type LocalHttpsServer = { + secureUrl: string + schemeLessUrl: string + documentRequestCount: () => number + assetRequestCount: () => number + webSocketConnectionCount: () => number + close: () => Promise +} + +export type LocalHttpProbeServer = { + url: string + close: () => Promise +} + +function closeServer(server: Server): Promise { + return new Promise((resolve, reject) => + server.close((error) => { + if (error) { + reject(error) + return + } + resolve() + }) + ) +} + +export async function startLocalHttpsServer(): Promise { + let documentRequestCount = 0 + let assetRequestCount = 0 + let webSocketConnectionCount = 0 + let secureOrigin = '' + const server = createHttpsServer( + { key: LOCAL_HTTPS_TEST_PRIVATE_KEY, cert: LOCAL_HTTPS_TEST_CERTIFICATE }, + (request, response) => { + const requestUrl = new URL(request.url ?? '/', secureOrigin) + if (requestUrl.pathname === '/asset.svg') { + assetRequestCount += 1 + response.writeHead(200, { + 'Cache-Control': 'no-store', + 'Content-Type': 'image/svg+xml; charset=utf-8' + }) + response.end('') + return + } + // Why: count only the root document. Favicon/probes would inflate the + // document counter and flake exact E2E request-count assertions. + if (requestUrl.pathname !== '/') { + response.writeHead(404) + response.end() + return + } + documentRequestCount += 1 + const socketUrl = `${secureOrigin.replace('https:', 'wss:')}/socket` + response.writeHead(200, { + 'Cache-Control': 'no-store', + 'Content-Type': 'text/html; charset=utf-8' + }) + response.end(` + + + Untrusted TLS page + +

Local HTTPS request ${documentRequestCount}

+ TLS asset + + + + `) + } + ) + const webSocketServer = new WebSocketServer({ noServer: true }) + server.on('upgrade', (request, socket, head) => { + if (request.url !== '/socket') { + socket.destroy() + return + } + webSocketServer.handleUpgrade(request, socket, head, (client) => { + webSocketServer.emit('connection', client, request) + }) + }) + webSocketServer.on('connection', (client) => { + webSocketConnectionCount += 1 + client.send('ready') + }) + + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)) + const port = (server.address() as AddressInfo).port + secureOrigin = `https://127.0.0.1:${port}` + + return { + secureUrl: `${secureOrigin}/`, + schemeLessUrl: `127.0.0.1:${port}/`, + documentRequestCount: () => documentRequestCount, + assetRequestCount: () => assetRequestCount, + webSocketConnectionCount: () => webSocketConnectionCount, + close: async () => { + for (const client of webSocketServer.clients) { + client.terminate() + } + await new Promise((resolve) => webSocketServer.close(() => resolve())) + await closeServer(server) + } + } +} + +export async function startLocalHttpProbeServer( + target: LocalHttpsServer +): Promise { + const assetUrl = new URL('/asset.svg', target.secureUrl).toString() + const socketUrl = new URL('/socket', target.secureUrl).toString().replace('https:', 'wss:') + const server = createHttpServer((_request, response) => { + response.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' }) + response.end(` + + + Sibling TLS probe + +

Sibling TLS probe

+ Sibling TLS asset + + + + `) + }) + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)) + const port = (server.address() as AddressInfo).port + return { url: `http://127.0.0.1:${port}/`, close: () => closeServer(server) } +} From d363c83ae33f3617797eb6065433107a482dc67c Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Thu, 16 Jul 2026 19:15:40 -0700 Subject: [PATCH 12/24] fix(worktrees): force-remove clean worktree with initialised submodule (#9096) Co-authored-by: Claude Fable 5 Co-authored-by: MarkXian --- src/main/git/remove-worktree.test.ts | 115 ++++++++++++++++++ src/main/git/worktree.ts | 17 ++- src/relay/git-handler-worktree-ops.test.ts | 111 +++++++++++++++++ src/relay/git-handler-worktree-remove.ts | 19 ++- src/shared/worktree-submodule-removal.test.ts | 39 ++++++ src/shared/worktree-submodule-removal.ts | 22 ++++ 6 files changed, 321 insertions(+), 2 deletions(-) create mode 100644 src/shared/worktree-submodule-removal.test.ts create mode 100644 src/shared/worktree-submodule-removal.ts diff --git a/src/main/git/remove-worktree.test.ts b/src/main/git/remove-worktree.test.ts index 121a8208754..c0789d1529c 100644 --- a/src/main/git/remove-worktree.test.ts +++ b/src/main/git/remove-worktree.test.ts @@ -280,6 +280,121 @@ branch refs/heads/main expect(getGitCalls()).toContain('git worktree remove --force /repo-feature') }) + it('force-retries removal when git refuses a clean worktree containing an initialised submodule', async () => { + mockGitCommands({ + 'git worktree list --porcelain': { + stdout: `worktree /repo +HEAD abc123 +branch refs/heads/main + +worktree /repo-feature +HEAD def456 +branch refs/heads/feature/test +` + }, + 'git worktree list --porcelain#2': { + stdout: `worktree /repo +HEAD abc123 +branch refs/heads/main +` + }, + 'git worktree remove /repo-feature': { + error: new Error('git worktree remove failed'), + stderr: 'fatal: working trees containing submodules cannot be moved or removed' + }, + 'git status --porcelain --untracked-files=all': { stdout: '' } + }) + + await removeWorktree('/repo', '/repo-feature') + + const calls = getGitCalls() + expectGitCallOrder( + calls, + 'git worktree remove /repo-feature', + 'git status --porcelain --untracked-files=all' + ) + expectGitCallOrder( + calls, + 'git status --porcelain --untracked-files=all', + 'git worktree remove --force /repo-feature' + ) + expect(calls).toContain('git branch -d -- feature/test') + }) + + it('surfaces uncommitted changes instead of force-removing a dirty submodule worktree', async () => { + mockGitCommands({ + 'git worktree list --porcelain': { + stdout: `worktree /repo +HEAD abc123 +branch refs/heads/main + +worktree /repo-feature +HEAD def456 +branch refs/heads/feature/test +` + }, + 'git worktree remove /repo-feature': { + error: new Error('git worktree remove failed'), + stderr: 'fatal: working trees containing submodules cannot be moved or removed' + }, + 'git status --porcelain --untracked-files=all': { stdout: ' M sub\n' } + }) + + await expect(removeWorktree('/repo', '/repo-feature')).rejects.toThrow( + 'Worktree has uncommitted or untracked changes.' + ) + expect(getGitCalls()).not.toContain('git worktree remove --force /repo-feature') + }) + + it('does not force-retry when the caller already forced removal', async () => { + mockGitCommands({ + 'git worktree list --porcelain': { + stdout: `worktree /repo +HEAD abc123 +branch refs/heads/main + +worktree /repo-feature +HEAD def456 +branch refs/heads/feature/test +` + }, + 'git worktree remove --force /repo-feature': { + error: new Error('git worktree remove failed'), + stderr: 'fatal: working trees containing submodules cannot be moved or removed' + } + }) + + await expect(removeWorktree('/repo', '/repo-feature', true)).rejects.toThrow() + expect( + getGitCalls().filter((call) => call === 'git worktree remove --force /repo-feature') + ).toHaveLength(1) + }) + + it('does not force-retry unrelated non-force remove failures', async () => { + mockGitCommands({ + 'git worktree list --porcelain': { + stdout: `worktree /repo +HEAD abc123 +branch refs/heads/main + +worktree /repo-feature +HEAD def456 +branch refs/heads/feature/test +` + }, + 'git worktree remove /repo-feature': { + error: new Error('git worktree remove failed'), + stderr: 'fatal: contains modified or untracked files, use --force to delete it' + } + }) + + await expect(removeWorktree('/repo', '/repo-feature')).rejects.toThrow( + 'git worktree remove failed' + ) + expect(getGitCalls()).not.toContain('git worktree remove --force /repo-feature') + expect(getGitCalls()).not.toContain('git status --porcelain --untracked-files=all') + }) + it('rejects a locked worktree with stable app-owned copy before invoking remove', async () => { mockGitCommands({ 'git worktree list --porcelain': { diff --git a/src/main/git/worktree.ts b/src/main/git/worktree.ts index 8823c683b60..84ce3963192 100644 --- a/src/main/git/worktree.ts +++ b/src/main/git/worktree.ts @@ -14,6 +14,7 @@ import type { RemoveWorktreeResult } from '../../shared/types' import { assertWorktreeUnlockedForRemoval } from '../../shared/worktree-removal' +import { isSubmoduleWorktreeRemovalRefusal } from '../../shared/worktree-submodule-removal' import { decodeGitCQuotedPath } from '../../shared/git-cquoted-path' import { parseGitRevListAheadBehindCounts } from '../../shared/git-rev-list-output' import { parseWslUncPath } from '../../shared/wsl-paths' @@ -1221,7 +1222,21 @@ async function performRemoveWorktree( args.push('--force') } args.push(worktreePath) - await gitExecFileAsync(args, gitExecOptions(repoPath, options)) + try { + await gitExecFileAsync(args, gitExecOptions(repoPath, options)) + } catch (error) { + if (force || !isSubmoduleWorktreeRemovalRefusal(error)) { + throw error + } + // Why: Git refuses non-force removal of any worktree with an initialised + // submodule even when everything is clean. Re-prove cleanliness (parent + // status reports dirty submodule content as ` M `), then --force. + await assertWorktreeCleanForRemoval(worktreePath, false, options) + await gitExecFileAsync( + ['worktree', 'remove', '--force', worktreePath], + gitExecOptions(repoPath, options) + ) + } if (!branchName) { return {} diff --git a/src/relay/git-handler-worktree-ops.test.ts b/src/relay/git-handler-worktree-ops.test.ts index ed252df6a46..cedcf8b797c 100644 --- a/src/relay/git-handler-worktree-ops.test.ts +++ b/src/relay/git-handler-worktree-ops.test.ts @@ -196,6 +196,117 @@ describe('removeWorktreeOp', () => { ]) }) + it('force-retries removal when git refuses a clean worktree containing an initialised submodule', async () => { + const calls: string[] = [] + let listCount = 0 + const git = vi.fn(async (args, cwd) => { + calls.push(`${cwd}$ ${args.join(' ')}`) + if (args[0] === 'rev-parse') { + return { stdout: '/repo/.git\n', stderr: '' } + } + if (args[0] === 'worktree' && args[1] === 'list') { + listCount += 1 + return { + stdout: + listCount === 1 + ? worktreeList( + { path: '/repo', branch: 'main' }, + { path: '/repo-feature', branch: 'feature/test' } + ) + : worktreeList({ path: '/repo', branch: 'main' }), + stderr: '' + } + } + if (args[0] === 'worktree' && args[1] === 'remove' && !args.includes('--force')) { + throw Object.assign(new Error('git worktree remove failed'), { + stderr: 'fatal: working trees containing submodules cannot be moved or removed' + }) + } + return { stdout: '', stderr: '' } + }) + + await removeWorktreeWithCapabilityCache(git, { worktreePath: '/repo-feature' }) + + expect(calls).toEqual([ + '/repo-feature$ rev-parse --git-common-dir', + `${resolvedRepoPath()}$ worktree list --porcelain -z`, + `${resolvedRepoPath()}$ worktree remove /repo-feature`, + '/repo-feature$ status --porcelain --untracked-files=all', + `${resolvedRepoPath()}$ worktree remove --force /repo-feature`, + `${resolvedRepoPath()}$ branch -d -- feature/test` + ]) + }) + + it('surfaces uncommitted changes instead of force-removing a dirty submodule worktree', async () => { + const git = vi.fn(async (args) => { + if (args[0] === 'rev-parse') { + return { stdout: '/repo/.git\n', stderr: '' } + } + if (args[0] === 'worktree' && args[1] === 'list') { + return { + stdout: worktreeList( + { path: '/repo', branch: 'main' }, + { path: '/repo-feature', branch: 'feature/test' } + ), + stderr: '' + } + } + if (args[0] === 'worktree' && args[1] === 'remove') { + throw Object.assign(new Error('git worktree remove failed'), { + stderr: 'fatal: working trees containing submodules cannot be moved or removed' + }) + } + if (args[0] === 'status') { + return { stdout: ' M sub\n', stderr: '' } + } + return { stdout: '', stderr: '' } + }) + + await expect( + removeWorktreeWithCapabilityCache(git, { worktreePath: '/repo-feature' }) + ).rejects.toThrow('Worktree has uncommitted or untracked changes.') + expect(git).not.toHaveBeenCalledWith( + ['worktree', 'remove', '--force', '/repo-feature'], + expect.any(String) + ) + }) + + it('does not force-retry when the caller already forced SSH removal', async () => { + const git = vi.fn(async (args) => { + if (args[0] === 'rev-parse') { + return { stdout: '/repo/.git\n', stderr: '' } + } + if (args[0] === 'worktree' && args[1] === 'list') { + return { + stdout: worktreeList( + { path: '/repo', branch: 'main' }, + { path: '/repo-feature', branch: 'feature/test' } + ), + stderr: '' + } + } + if (args[0] === 'worktree' && args[1] === 'remove') { + throw Object.assign(new Error('git worktree remove failed'), { + stderr: 'fatal: working trees containing submodules cannot be moved or removed' + }) + } + return { stdout: '', stderr: '' } + }) + + await expect( + removeWorktreeWithCapabilityCache(git, { worktreePath: '/repo-feature', force: true }) + ).rejects.toThrow('git worktree remove failed') + expect( + git.mock.calls.filter( + ([args]) => args[0] === 'worktree' && args[1] === 'remove' && args.includes('--force') + ) + ).toHaveLength(1) + expect(git).not.toHaveBeenCalledWith( + ['status', '--porcelain', '--untracked-files=all'], + expect.any(String) + ) + }) + it('preserves the branch (does not throw) when `branch -d` refuses an unmerged branch', async () => { let listCount = 0 const git = vi.fn(async (args) => { diff --git a/src/relay/git-handler-worktree-remove.ts b/src/relay/git-handler-worktree-remove.ts index 2c2ce54a8cc..46ebaf8a355 100644 --- a/src/relay/git-handler-worktree-remove.ts +++ b/src/relay/git-handler-worktree-remove.ts @@ -1,6 +1,7 @@ import * as path from 'node:path' import type { RemoveWorktreeResult } from '../shared/types' import { assertWorktreeUnlockedForRemoval } from '../shared/worktree-removal' +import { isSubmoduleWorktreeRemovalRefusal } from '../shared/worktree-submodule-removal' import { deleteAlreadyMergedRelayBranchAfterSafeDeleteFailure } from './git-handler-branch-cleanup' import type { GitExec } from './git-handler-ops' import type { GitCapabilityCache } from '../shared/git-capability-cache' @@ -156,7 +157,23 @@ export async function removeWorktreeOp( args.push('--force') } args.push(worktreePath) - await git(args, repoPath) + try { + await git(args, repoPath) + } catch (error) { + if (force || !isSubmoduleWorktreeRemovalRefusal(error)) { + throw error + } + // Why: Git refuses non-force removal of any worktree with an initialised + // submodule even when everything is clean. Re-prove cleanliness (parent + // status reports dirty submodule content as ` M `), then --force. + const { stdout } = await git(['status', '--porcelain', '--untracked-files=all'], worktreePath) + if (stdout.trim()) { + const dirtyError = new Error('Worktree has uncommitted or untracked changes.') + ;(dirtyError as Error & { stdout?: string }).stdout = stdout + throw dirtyError + } + await git(['worktree', 'remove', '--force', worktreePath], repoPath) + } if (!branchName) { return {} diff --git a/src/shared/worktree-submodule-removal.test.ts b/src/shared/worktree-submodule-removal.test.ts new file mode 100644 index 00000000000..0c8564b79d3 --- /dev/null +++ b/src/shared/worktree-submodule-removal.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, it } from 'vitest' +import { isSubmoduleWorktreeRemovalRefusal } from './worktree-submodule-removal' + +describe('isSubmoduleWorktreeRemovalRefusal', () => { + it('matches the English git fatal on stderr', () => { + expect( + isSubmoduleWorktreeRemovalRefusal( + Object.assign(new Error('git worktree remove failed'), { + stderr: 'fatal: working trees containing submodules cannot be moved or removed\n' + }) + ) + ).toBe(true) + }) + + it('matches when the refusal is only in the error message', () => { + expect( + isSubmoduleWorktreeRemovalRefusal( + new Error('fatal: working trees containing submodules cannot be moved or removed') + ) + ).toBe(true) + }) + + it('does not match dirty-worktree or lock refusals', () => { + expect( + isSubmoduleWorktreeRemovalRefusal( + Object.assign(new Error('git worktree remove failed'), { + stderr: 'fatal: contains modified or untracked files, use --force to delete it' + }) + ) + ).toBe(false) + expect( + isSubmoduleWorktreeRemovalRefusal( + Object.assign(new Error('git worktree remove failed'), { + stderr: 'fatal: cannot remove a locked working tree' + }) + ) + ).toBe(false) + }) +}) diff --git a/src/shared/worktree-submodule-removal.ts b/src/shared/worktree-submodule-removal.ts new file mode 100644 index 00000000000..8a6f91a2cf8 --- /dev/null +++ b/src/shared/worktree-submodule-removal.ts @@ -0,0 +1,22 @@ +function getErrorText(error: unknown): string { + if (typeof error === 'object' && error !== null) { + const parts: string[] = [] + for (const field of ['message', 'stderr', 'stdout'] as const) { + const value = (error as Record)[field] + if (typeof value === 'string' && value) { + parts.push(value) + } + } + return parts.join('\n') + } + return String(error) +} + +// Why: `git worktree remove` (non-force) categorically refuses any worktree +// containing an initialised submodule, even when parent and submodule are +// fully clean (validate_no_submodules, Git >= 2.17). Callers re-prove +// cleanliness and retry with --force. Both the local runner and the relay pin +// English git output (UNTRANSLATED_GIT_OUTPUT_ENV), so text matching is stable. +export function isSubmoduleWorktreeRemovalRefusal(error: unknown): boolean { + return /working trees containing submodules cannot be moved or removed/i.test(getErrorText(error)) +} From 53af61160a8f2074cde9851c470b1c991f4479e4 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Thu, 16 Jul 2026 19:20:05 -0700 Subject: [PATCH 13/24] fix(settings): keep Shortcuts rows visible on pane-title-only search (#9097) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: 조재중 <126754298+m-a-king@users.noreply.github.com> --- .../settings/ShortcutCommandBlock.tsx | 3 ++ .../settings/ShortcutFilterRail.test.ts | 35 +++++++++++++++ .../settings/ShortcutFilterRail.tsx | 13 +++++- .../src/components/settings/ShortcutsPane.tsx | 5 ++- .../settings-search-shortcuts-pane.spec.ts | 43 +++++++++++++++++++ 5 files changed, 96 insertions(+), 3 deletions(-) create mode 100644 tests/e2e/settings-search-shortcuts-pane.spec.ts diff --git a/src/renderer/src/components/settings/ShortcutCommandBlock.tsx b/src/renderer/src/components/settings/ShortcutCommandBlock.tsx index 853e6ae3c62..c4eaa9cbe84 100644 --- a/src/renderer/src/components/settings/ShortcutCommandBlock.tsx +++ b/src/renderer/src/components/settings/ShortcutCommandBlock.tsx @@ -106,6 +106,8 @@ export function ShortcutCommandBlock({ /> ) + // Why: ShortcutsPane already filters rows against the global query; + // forceVisible skips the re-match here that would hide rows it kept. return (
diff --git a/src/renderer/src/components/settings/ShortcutFilterRail.test.ts b/src/renderer/src/components/settings/ShortcutFilterRail.test.ts index d979415798a..8dd6a039010 100644 --- a/src/renderer/src/components/settings/ShortcutFilterRail.test.ts +++ b/src/renderer/src/components/settings/ShortcutFilterRail.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest' import type { KeybindingActionId } from '../../../../shared/keybindings' import { SHORTCUT_LOCAL_SEARCH_QUERY_MAX_BYTES, + buildShortcutGlobalSearchMatcher, isShortcutLocalSearchQueryTooLarge, matchesShortcutLocalSearch, normalizeShortcutLocalSearchQuery, @@ -52,6 +53,40 @@ describe('ShortcutFilterRail search helpers', () => { expect(normalizeShortcutLocalSearchQuery(query)).toBeNull() }) + it('narrows rows when the global settings query matches specific rows', () => { + const settingsRow = createShortcutRow() + const worktreeRow: ShortcutRowModel = { + ...createShortcutRow(), + item: { + ...createShortcutRow().item, + id: 'worktree.create' as KeybindingActionId, + title: 'Create worktree', + searchKeywords: ['worktree', 'create'] + } + } + + const matcher = buildShortcutGlobalSearchMatcher([settingsRow, worktreeRow], 'worktree') + + expect(matcher(settingsRow)).toBe(false) + expect(matcher(worktreeRow)).toBe(true) + }) + + it('keeps every row visible on a pane-title-only global query', () => { + const rows = [createShortcutRow()] + + // Mirrors sidebar search selecting the pane with a query that matches only + // pane-level metadata (e.g. a localized pane title) and no row metadata. + const matcher = buildShortcutGlobalSearchMatcher(rows, '단축키') + + expect(matcher(rows[0])).toBe(true) + }) + + it('keeps every row visible when the global query is empty', () => { + const rows = [createShortcutRow()] + + expect(buildShortcutGlobalSearchMatcher(rows, '')(rows[0])).toBe(true) + }) + it('rejects oversized pasted shortcut searches before reading row metadata', () => { const oversizedQuery = 'secret-shortcut-search'.repeat(SHORTCUT_LOCAL_SEARCH_QUERY_MAX_BYTES) const row = { diff --git a/src/renderer/src/components/settings/ShortcutFilterRail.tsx b/src/renderer/src/components/settings/ShortcutFilterRail.tsx index 76445a67e9a..222127f88ed 100644 --- a/src/renderer/src/components/settings/ShortcutFilterRail.tsx +++ b/src/renderer/src/components/settings/ShortcutFilterRail.tsx @@ -6,7 +6,7 @@ import { cn } from '../../lib/utils' import { Button } from '../ui/button' import { Input } from '../ui/input' import type { ShortcutTerminalStatus } from './shortcut-terminal-status' -import type { SettingsSearchEntry } from './settings-search' +import { matchesSettingsSearch, type SettingsSearchEntry } from './settings-search' import { translate } from '@/i18n/i18n' export type ShortcutFilter = 'all' | 'modified' | 'unassigned' | 'conflicts' @@ -60,6 +60,17 @@ export function getShortcutSearchEntry(row: ShortcutRowModel): SettingsSearchEnt } } +// Why: a sidebar query can select this pane by title alone while matching zero +// rows; that would blank the list, so zero-row queries keep every row visible. +export function buildShortcutGlobalSearchMatcher( + rows: readonly ShortcutRowModel[], + searchQuery: string +): (row: ShortcutRowModel) => boolean { + const rowMatches = (row: ShortcutRowModel): boolean => + matchesSettingsSearch(searchQuery, getShortcutSearchEntry(row)) + return rows.some(rowMatches) ? rowMatches : () => true +} + export function matchesShortcutFilter(row: ShortcutRowModel, filter: ShortcutFilter): boolean { switch (filter) { case 'modified': diff --git a/src/renderer/src/components/settings/ShortcutsPane.tsx b/src/renderer/src/components/settings/ShortcutsPane.tsx index b6fe5b3556c..29f9661d196 100644 --- a/src/renderer/src/components/settings/ShortcutsPane.tsx +++ b/src/renderer/src/components/settings/ShortcutsPane.tsx @@ -25,7 +25,7 @@ import { sameBindings } from './keybinding-override-edits' import { - getShortcutSearchEntry, + buildShortcutGlobalSearchMatcher, matchesShortcutFilter, matchesShortcutLocalSearch, normalizeShortcutLocalSearchQuery, @@ -138,9 +138,10 @@ export function ShortcutsPane(): React.JSX.Element { ) const shortcutSearchQuery = normalizeShortcutLocalSearchQuery(shortcutQuery) const shortcutRows = shortcutGroups.flatMap((group) => group.rows) + const matchesShortcutGlobalSearch = buildShortcutGlobalSearchMatcher(shortcutRows, searchQuery) const matchesShortcutSearch = (row: ShortcutRowsByGroup['rows'][number]): boolean => shortcutSearchQuery !== null && - matchesSettingsSearch(searchQuery, getShortcutSearchEntry(row)) && + matchesShortcutGlobalSearch(row) && matchesShortcutLocalSearch(row, shortcutSearchQuery, platform) const baseVisibleRows = shortcutRows.filter((row) => matchesShortcutSearch(row)) const filterCounts: Record = { diff --git a/tests/e2e/settings-search-shortcuts-pane.spec.ts b/tests/e2e/settings-search-shortcuts-pane.spec.ts new file mode 100644 index 00000000000..17e8097083b --- /dev/null +++ b/tests/e2e/settings-search-shortcuts-pane.spec.ts @@ -0,0 +1,43 @@ +import { test, expect } from './helpers/orca-app' +import { waitForSessionReady } from './helpers/store' + +test.describe('Settings sidebar search on the Shortcuts pane', () => { + test('pane-title-only query keeps rows visible and local search usable', async ({ orcaPage }) => { + await waitForSessionReady(orcaPage) + + await orcaPage.evaluate(async () => { + const store = window.__store + if (!store) { + throw new Error('window.__store is not available') + } + // Why: the spec asserts on English strings; the host machine may run a + // non-English system locale, which 'system' would follow. + await store.getState().updateSettings({ uiLanguage: 'en' }) + store.getState().openSettingsPage() + }) + + const searchInput = orcaPage.getByPlaceholder('Search settings') + await expect(searchInput).toBeVisible() + await searchInput.fill('shortcuts') + + // The query matches the pane title, so the Shortcuts pane auto-activates. + await expect(orcaPage.getByRole('heading', { name: 'Shortcuts', exact: true })).toBeVisible() + + // Regression: a pane-title-only query used to blank the whole list (0/112). + await expect(orcaPage.getByText('Go to File', { exact: true })).toBeVisible() + await expect(orcaPage.getByText('No shortcuts match those filters.')).not.toBeVisible() + + // Regression: the pane's own search was dead while the global query was + // active, because it intersected with an already-empty base list. + const localSearch = orcaPage.getByPlaceholder('Search command or keys') + await localSearch.fill('go to') + await expect(orcaPage.getByText('Go to File', { exact: true })).toBeVisible() + await expect(orcaPage.getByText('Force Reload', { exact: true })).not.toBeVisible() + + // A row-matching global query still narrows the list as before. + await localSearch.clear() + await searchInput.fill('worktree') + await expect(orcaPage.getByText('Create worktree', { exact: true })).toBeVisible() + await expect(orcaPage.getByText('Go to File', { exact: true })).not.toBeVisible() + }) +}) From a7c40cd89b2fde220c04abbc24289bb38456da3b Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Thu, 16 Jul 2026 19:21:09 -0700 Subject: [PATCH 14/24] fix(terminal): stop gluing the next line onto a modifier-clicked URL (#9100) Fixes #8832. HTTP hit-testing no longer consumes path hard-wrap candidates that poisoned mid-row URLs with next-line labels. Uses soft-wrap, framed hard-wrap HTTP, and dedicated edge-wrap reconstruction instead. Supersedes #8852 (edge-wrap approach credit: @xianjianlf2). --- .../edge-wrapped-terminal-http-links.test.ts | 425 ++++++++++++++++++ .../edge-wrapped-terminal-http-links.ts | 183 ++++++++ .../repro-8832-url-next-line.test.ts | 203 +++++++++ .../terminal-url-link-click.test.ts | 38 ++ .../terminal-url-link-hit-testing.ts | 33 +- 5 files changed, 866 insertions(+), 16 deletions(-) create mode 100644 src/renderer/src/components/terminal-pane/edge-wrapped-terminal-http-links.test.ts create mode 100644 src/renderer/src/components/terminal-pane/edge-wrapped-terminal-http-links.ts create mode 100644 src/renderer/src/components/terminal-pane/repro-8832-url-next-line.test.ts diff --git a/src/renderer/src/components/terminal-pane/edge-wrapped-terminal-http-links.test.ts b/src/renderer/src/components/terminal-pane/edge-wrapped-terminal-http-links.test.ts new file mode 100644 index 00000000000..9289f67b7b2 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/edge-wrapped-terminal-http-links.test.ts @@ -0,0 +1,425 @@ +import type { IBufferLine } from '@xterm/xterm' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { registerHttpLinkStoreAccessor } from '@/lib/http-link-routing' +import { openHttpLinkAtBufferPosition } from './terminal-url-link-hit-testing' + +const COLS = 40 +const WIDE_GLYPH = '界' +const openUrlMock = vi.fn() + +function makeBufferLine(content: string, cols = COLS): IBufferLine { + return makeBufferLineFromCells( + Array.from(content, (text) => ({ text, width: text === WIDE_GLYPH ? 2 : 1 })), + cols + ) +} + +function makeBufferLineFromCells( + cells: { text: string; width: number }[], + cols = COLS +): IBufferLine { + const columns: number[] = [] + let column = 0 + let text = '' + for (const cell of cells) { + text += cell.text + for (let index = 0; index < cell.text.length; index++) { + columns.push(column) + } + column += cell.width + } + while (column < cols) { + text += ' ' + columns.push(column) + column++ + } + columns.push(column) + + return { + isWrapped: false, + length: cols, + translateToString: ( + _trimRight?: boolean, + startColumn = 0, + endColumn = text.length, + outColumns?: number[] + ) => { + outColumns?.splice(0, outColumns.length, ...columns.slice(startColumn, endColumn + 1)) + return text.slice(startColumn, endColumn) + } + } as IBufferLine +} + +describe('edge-wrapped terminal HTTP links', () => { + beforeEach(() => { + vi.stubGlobal('window', { api: { shell: { openUrl: openUrlMock } } }) + registerHttpLinkStoreAccessor(() => ({ + settings: { openLinksInApp: false }, + setActiveWorktree: vi.fn(), + createBrowserTab: vi.fn() + })) + openUrlMock.mockReset() + }) + + afterEach(() => { + vi.unstubAllGlobals() + }) + + it('opens a URL when a wide glyph starts the continuation row', () => { + const prefix = 'https://example.com/wide/' + const firstRow = `${prefix}${'c'.repeat(COLS - 1 - prefix.length)}` + const continuationRow = `${WIDE_GLYPH}tail` + const expectedUrl = new URL(`${firstRow}${continuationRow}`).toString() + const rows = [makeBufferLine(firstRow), makeBufferLine(continuationRow)] + const buffer = { getLine: (y: number) => rows[y] } + + expect( + openHttpLinkAtBufferPosition(buffer, { x: 11, y: 1 }, COLS, { + worktreeId: 'wt-1', + forceSystemBrowser: true + }) + ).toBe(true) + expect(openUrlMock).toHaveBeenCalledOnce() + expect(openUrlMock).toHaveBeenCalledWith(expectedUrl) + + openUrlMock.mockReset() + expect( + openHttpLinkAtBufferPosition(buffer, { x: 3, y: 2 }, COLS, { + worktreeId: 'wt-1', + forceSystemBrowser: true + }) + ).toBe(true) + expect(openUrlMock).toHaveBeenCalledOnce() + expect(openUrlMock).toHaveBeenCalledWith(expectedUrl) + }) + + it('opens a URL when a multi-code-point wide cell starts the continuation row', () => { + const wideCell = '👩‍💻' + const prefix = 'https://example.com/wide/' + const firstRow = `${prefix}${'c'.repeat(COLS - 1 - prefix.length)}` + const continuationRow = `${wideCell}tail` + const expectedUrl = new URL(`${firstRow}${continuationRow}`).toString() + const rows = [ + makeBufferLine(firstRow), + makeBufferLineFromCells([ + { text: wideCell, width: 2 }, + ...Array.from('tail', (text) => ({ text, width: 1 })) + ]) + ] + const buffer = { getLine: (y: number) => rows[y] } + + expect( + openHttpLinkAtBufferPosition(buffer, { x: 11, y: 1 }, COLS, { + worktreeId: 'wt-1', + forceSystemBrowser: true + }) + ).toBe(true) + expect(openUrlMock).toHaveBeenCalledOnce() + expect(openUrlMock).toHaveBeenCalledWith(expectedUrl) + + openUrlMock.mockReset() + expect( + openHttpLinkAtBufferPosition(buffer, { x: 1, y: 2 }, COLS, { + worktreeId: 'wt-1', + forceSystemBrowser: true + }) + ).toBe(true) + expect(openUrlMock).toHaveBeenCalledOnce() + expect(openUrlMock).toHaveBeenCalledWith(expectedUrl) + }) + + it('does not use the wide-glyph tolerance for an ASCII continuation row', () => { + const firstRow = `https://example.com/${'a'.repeat(COLS - 1 - 'https://example.com/'.length)}` + const rows = [makeBufferLine(firstRow), makeBufferLine('next')] + const buffer = { getLine: (y: number) => rows[y] } + + expect( + openHttpLinkAtBufferPosition(buffer, { x: 11, y: 1 }, COLS, { + worktreeId: 'wt-1', + forceSystemBrowser: true + }) + ).toBe(true) + expect(openUrlMock).toHaveBeenCalledOnce() + expect(openUrlMock).toHaveBeenCalledWith(firstRow) + + openUrlMock.mockReset() + expect( + openHttpLinkAtBufferPosition(buffer, { x: 2, y: 2 }, COLS, { + worktreeId: 'wt-1', + forceSystemBrowser: true + }) + ).toBe(false) + expect(openUrlMock).not.toHaveBeenCalled() + }) + + it.each(['Description: 123', 'Description:', '404: Not Found', 'HTTP/2: 200', 'HTTP/2:200'])( + 'does not append a %s label when a complete URL ends at the terminal edge', + (labelRow) => { + const firstRow = `https://example.com/${'a'.repeat(COLS - 1 - 'https://example.com/'.length)}/` + const rows = [makeBufferLine(firstRow), makeBufferLine(labelRow)] + const buffer = { getLine: (y: number) => rows[y] } + + expect(firstRow).toHaveLength(COLS) + expect( + openHttpLinkAtBufferPosition(buffer, { x: 11, y: 1 }, COLS, { + worktreeId: 'wt-1', + forceSystemBrowser: true + }) + ).toBe(true) + expect(openUrlMock).toHaveBeenCalledOnce() + expect(openUrlMock).toHaveBeenCalledWith(firstRow) + + openUrlMock.mockReset() + expect( + openHttpLinkAtBufferPosition(buffer, { x: 2, y: 2 }, COLS, { + worktreeId: 'wt-1', + forceSystemBrowser: true + }) + ).toBe(false) + expect(openUrlMock).not.toHaveBeenCalled() + } + ) + + it('does not append an adjacent URL that starts on the next row', () => { + const firstRow = `https://example.com/${'a'.repeat(COLS - 'https://example.com/'.length)}` + const secondRow = 'https://two.test/path' + const rows = [makeBufferLine(firstRow), makeBufferLine(secondRow)] + const buffer = { getLine: (y: number) => rows[y] } + + expect( + openHttpLinkAtBufferPosition(buffer, { x: 11, y: 1 }, COLS, { + worktreeId: 'wt-1', + forceSystemBrowser: true + }) + ).toBe(true) + expect(openUrlMock).toHaveBeenCalledOnce() + expect(openUrlMock).toHaveBeenCalledWith(firstRow) + + openUrlMock.mockReset() + expect( + openHttpLinkAtBufferPosition(buffer, { x: 11, y: 2 }, COLS, { + worktreeId: 'wt-1', + forceSystemBrowser: true + }) + ).toBe(true) + expect(openUrlMock).toHaveBeenCalledOnce() + expect(openUrlMock).toHaveBeenCalledWith(secondRow) + }) + + it('keeps a full-width continuation row that ends in a colon', () => { + const prefix = 'https://example.com/' + const firstRow = `${prefix}${'a'.repeat(COLS - prefix.length)}` + const secondRow = `${'b'.repeat(COLS - 1)}:` + const thirdRow = 'tail' + const expectedUrl = new URL(`${firstRow}${secondRow}${thirdRow}`).toString() + const rows = [makeBufferLine(firstRow), makeBufferLine(secondRow), makeBufferLine(thirdRow)] + const buffer = { getLine: (y: number) => rows[y] } + + expect( + openHttpLinkAtBufferPosition(buffer, { x: 11, y: 1 }, COLS, { + worktreeId: 'wt-1', + forceSystemBrowser: true + }) + ).toBe(true) + expect(openUrlMock).toHaveBeenCalledOnce() + expect(openUrlMock).toHaveBeenCalledWith(expectedUrl) + + openUrlMock.mockReset() + expect( + openHttpLinkAtBufferPosition(buffer, { x: 20, y: 2 }, COLS, { + worktreeId: 'wt-1', + forceSystemBrowser: true + }) + ).toBe(true) + expect(openUrlMock).toHaveBeenCalledOnce() + expect(openUrlMock).toHaveBeenCalledWith(expectedUrl) + }) + + it('keeps a short colon-bearing URL continuation', () => { + const firstRow = `https://example.com/${'a'.repeat(COLS - 'https://example.com/'.length)}` + const secondRow = 'urn:abc' + const expectedUrl = `${firstRow}${secondRow}` + const rows = [makeBufferLine(firstRow), makeBufferLine(secondRow)] + const buffer = { getLine: (y: number) => rows[y] } + + expect( + openHttpLinkAtBufferPosition(buffer, { x: 4, y: 2 }, COLS, { + worktreeId: 'wt-1', + forceSystemBrowser: true + }) + ).toBe(true) + expect(openUrlMock).toHaveBeenCalledOnce() + expect(openUrlMock).toHaveBeenCalledWith(expectedUrl) + }) + + it('does not count text before the URL against the URL length limit', () => { + const cols = 200 + const prefix = `${'P'.repeat(179)} ` + const url = `https://example.com/${'a'.repeat(1_880)}` + const displayed = `${prefix}${url}` + const rowTexts = Array.from({ length: Math.ceil(displayed.length / cols) }, (_value, index) => + displayed.slice(index * cols, (index + 1) * cols) + ) + const rows = rowTexts.map((row) => makeBufferLine(row, cols)) + const buffer = { getLine: (y: number) => rows[y] } + + expect(url).toHaveLength(1_900) + expect(displayed.length).toBeGreaterThan(2_048) + expect( + openHttpLinkAtBufferPosition(buffer, { x: 191, y: 1 }, cols, { + worktreeId: 'wt-1', + forceSystemBrowser: true + }) + ).toBe(true) + expect(openUrlMock).toHaveBeenCalledOnce() + expect(openUrlMock).toHaveBeenCalledWith(url) + + openUrlMock.mockReset() + expect( + openHttpLinkAtBufferPosition(buffer, { x: 10, y: rows.length }, cols, { + worktreeId: 'wt-1', + forceSystemBrowser: true + }) + ).toBe(true) + expect(openUrlMock).toHaveBeenCalledOnce() + expect(openUrlMock).toHaveBeenCalledWith(url) + }) + + it('reconstructs a narrow-terminal URL spanning more than 20 rows from either end', () => { + const url = `https://example.com/${'a'.repeat(1_000)}` + const rowTexts = Array.from({ length: Math.ceil(url.length / COLS) }, (_value, index) => + url.slice(index * COLS, (index + 1) * COLS) + ) + const rows = rowTexts.map((row) => makeBufferLine(row)) + const buffer = { getLine: (y: number) => rows[y] } + + expect(url.length).toBeLessThanOrEqual(2_048) + expect(rowTexts.length).toBeGreaterThan(20) + expect( + openHttpLinkAtBufferPosition(buffer, { x: 11, y: 1 }, COLS, { + worktreeId: 'wt-1', + forceSystemBrowser: true + }) + ).toBe(true) + expect(openUrlMock).toHaveBeenCalledOnce() + expect(openUrlMock).toHaveBeenCalledWith(url) + + openUrlMock.mockReset() + expect( + openHttpLinkAtBufferPosition(buffer, { x: 5, y: rowTexts.length }, COLS, { + worktreeId: 'wt-1', + forceSystemBrowser: true + }) + ).toBe(true) + expect(openUrlMock).toHaveBeenCalledOnce() + expect(openUrlMock).toHaveBeenCalledWith(url) + }) + + it('reconstructs a wide-glyph URL whose row span exceeds the single-width column bound', () => { + const wideCount = 1_100 + const url = `https://example.com/${WIDE_GLYPH.repeat(wideCount)}` + const rowTexts: string[] = [] + let row = '' + let width = 0 + for (const char of url) { + const charWidth = char === WIDE_GLYPH ? 2 : 1 + if (width + charWidth > COLS) { + rowTexts.push(row) + row = '' + width = 0 + } + row += char + width += charWidth + } + if (row.length > 0) { + rowTexts.push(row) + } + const rows = rowTexts.map((rowText) => makeBufferLine(rowText)) + const buffer = { getLine: (y: number) => rows[y] } + + expect(url.length).toBeLessThanOrEqual(2_048) + expect(rowTexts.length).toBeGreaterThan(Math.ceil(2_048 / COLS) + 1) + expect( + openHttpLinkAtBufferPosition(buffer, { x: 5, y: rowTexts.length }, COLS, { + worktreeId: 'wt-1', + forceSystemBrowser: true + }) + ).toBe(true) + expect(openUrlMock).toHaveBeenCalledOnce() + expect(openUrlMock).toHaveBeenCalledWith(new URL(url).toString()) + }) + + it('joins a real multi-line URL whose continuation is a Chinese path segment', () => { + const prefix = 'https://example.com/' + const firstRow = `${prefix}${'a'.repeat(COLS - prefix.length)}` + const continuation = `${WIDE_GLYPH}文档/路径` + const expectedUrl = new URL(`${firstRow}${continuation}`).toString() + const rows = [makeBufferLine(firstRow), makeBufferLine(continuation)] + const buffer = { getLine: (y: number) => rows[y] } + + expect(firstRow).toHaveLength(COLS) + expect( + openHttpLinkAtBufferPosition(buffer, { x: 11, y: 1 }, COLS, { + worktreeId: 'wt-1', + forceSystemBrowser: true + }) + ).toBe(true) + expect(openUrlMock).toHaveBeenCalledOnce() + expect(openUrlMock).toHaveBeenCalledWith(expectedUrl) + }) + + it('does not glue a mid-row URL to a next-line Windows path', () => { + const firstRow = 'Repo: https://example.com/repo/' + const secondRow = 'C:\\Users\\demo\\project\\file.ts' + const rows = [makeBufferLine(firstRow), makeBufferLine(secondRow)] + const buffer = { getLine: (y: number) => rows[y] } + + expect( + openHttpLinkAtBufferPosition(buffer, { x: firstRow.indexOf('https') + 8, y: 1 }, COLS, { + worktreeId: 'wt-1', + forceSystemBrowser: true + }) + ).toBe(true) + expect(openUrlMock).toHaveBeenCalledOnce() + expect(openUrlMock).toHaveBeenCalledWith('https://example.com/repo/') + }) + + it('does not create an edge candidate for a scheme embedded in a word', () => { + const embeddedUrl = `abchttps://example.com/${'a'.repeat( + COLS - 'abchttps://example.com/'.length + )}` + const rows = [makeBufferLine(embeddedUrl), makeBufferLine('tail')] + const buffer = { getLine: (y: number) => rows[y] } + + expect( + openHttpLinkAtBufferPosition(buffer, { x: 10, y: 1 }, COLS, { + worktreeId: 'wt-1', + forceSystemBrowser: true + }) + ).toBe(false) + expect(openUrlMock).not.toHaveBeenCalled() + }) + + it('keeps a URL under the length limit when its final row also contains a label', () => { + const cols = 200 + const url = `https://example.com/${'a'.repeat(2_020)}` + const displayed = `${url} ${'L'.repeat(159)}` + const rowTexts = Array.from({ length: Math.ceil(displayed.length / cols) }, (_value, index) => + displayed.slice(index * cols, (index + 1) * cols) + ) + const rows = rowTexts.map((row) => makeBufferLine(row, cols)) + const buffer = { getLine: (y: number) => rows[y] } + + expect(url).toHaveLength(2_040) + expect(rowTexts).toHaveLength(11) + expect(rowTexts.at(-1)).toHaveLength(cols) + expect( + openHttpLinkAtBufferPosition(buffer, { x: 10, y: 1 }, cols, { + worktreeId: 'wt-1', + forceSystemBrowser: true + }) + ).toBe(true) + expect(openUrlMock).toHaveBeenCalledOnce() + expect(openUrlMock).toHaveBeenCalledWith(url) + }) +}) diff --git a/src/renderer/src/components/terminal-pane/edge-wrapped-terminal-http-links.ts b/src/renderer/src/components/terminal-pane/edge-wrapped-terminal-http-links.ts new file mode 100644 index 00000000000..48e10fabedd --- /dev/null +++ b/src/renderer/src/components/terminal-pane/edge-wrapped-terminal-http-links.ts @@ -0,0 +1,183 @@ +import type { IBufferLine } from '@xterm/xterm' +import { TERMINAL_HTTP_URL_MAX_LENGTH } from './terminal-http-link-limits' +import { translateLineWithColumns, type WrappedLogicalLine } from './wrapped-terminal-link-ranges' + +const HTTP_SCHEME_PATTERN = /https?:\/\//i +const HTTP_SCHEME_START_PATTERN = /^https?:\/\//i +const HTTP_STATUS_ROW_PATTERN = /^HTTP\/\d(?:\.\d)?:\d{3}(?:\s|$)/i +const VERTICAL_LAYOUT_FRAME_PATTERN = /[│┃║╎╏┆┇┊┋|]/ +const EMPTY_LABEL_ROW_PATTERN = /^[^\s:][^:]*:$/ +const LABEL_WITH_SPACING_ROW_PATTERN = /^[^\s:][^:]*:\s/ + +function maxEdgeWrappedHttpRows(lineLength: number): number { + // Why: every continued row reaches the terminal edge, so even all-width-2 + // cells carry at least floor(columns / 2) characters per row; the URL length + // limit then bounds the scan without truncating URLs on narrow terminals. + // The +2 covers the start and tail rows, which may hold only a fragment. + const minCharsPerFullRow = Math.max(1, Math.floor(lineLength / 2)) + return Math.ceil(TERMINAL_HTTP_URL_MAX_LENGTH / minCharsPerFullRow) + 2 +} + +type TrimmedTranslatedLine = { + text: string + sourceText: string + columns: number[] + lineLength: number +} + +function trimRightTranslatedLine(line: IBufferLine): TrimmedTranslatedLine | null { + const translated = translateLineWithColumns(line) + let endIndex = translated.text.length + while (endIndex > 0 && /\s/.test(translated.text[endIndex - 1])) { + endIndex-- + } + if (endIndex === 0) { + return null + } + return { + text: translated.text.slice(0, endIndex), + sourceText: translated.text, + columns: translated.columns.slice(0, endIndex + 1), + lineLength: line.length + } +} + +function firstCellWidth(row: TrimmedTranslatedLine): number { + const firstColumn = row.columns[0] + if (firstColumn === undefined) { + return 0 + } + const nextColumn = row.columns.find((column) => column > firstColumn) + return nextColumn === undefined ? 0 : nextColumn - firstColumn +} + +function isAsciiWordCode(code: number): boolean { + return ( + (code >= 48 && code <= 57) || + (code >= 65 && code <= 90) || + code === 95 || + (code >= 97 && code <= 122) + ) +} + +function findHttpSchemeIndex(text: string): number { + let searchStart = 0 + while (searchStart < text.length) { + const relativeIndex = text.slice(searchStart).search(HTTP_SCHEME_PATTERN) + if (relativeIndex === -1) { + return -1 + } + const schemeIndex = searchStart + relativeIndex + if (schemeIndex === 0 || !isAsciiWordCode(text.charCodeAt(schemeIndex - 1))) { + return schemeIndex + } + searchStart = schemeIndex + 1 + } + return -1 +} + +function rowsCanContinueAtTerminalEdge( + row: WrappedLogicalLine['rows'][number], + nextRow: TrimmedTranslatedLine +): boolean { + // Why: a cursor-positioned label is not URL continuation evidence even when + // the preceding URL happens to end at the terminal edge (#8832). + const nextColumnAfterText = nextRow.columns.at(-1) + const nextRowReachesEdge = + nextColumnAfterText !== undefined && nextColumnAfterText >= nextRow.lineLength + const isLabelRow = + LABEL_WITH_SPACING_ROW_PATTERN.test(nextRow.text) || + ((EMPTY_LABEL_ROW_PATTERN.test(nextRow.text) || HTTP_STATUS_ROW_PATTERN.test(nextRow.text)) && + !nextRowReachesEdge) + if (isLabelRow || HTTP_SCHEME_START_PATTERN.test(nextRow.text)) { + return false + } + const columnAfterText = row.columns.at(-1) + if (columnAfterText === undefined || columnAfterText < row.lineLength - 1) { + return false + } + return columnAfterText >= row.lineLength || firstCellWidth(nextRow) > 1 +} + +export function buildEdgeWrappedHttpLogicalLineCandidates( + buffer: { getLine(y: number): IBufferLine | undefined }, + bufferLineNumber: number +): WrappedLogicalLine[] { + // Why: cursor-positioned output lacks xterm wrap metadata, but an HTTP URL + // may still continue when each earlier row reaches the terminal edge. + const currentY = bufferLineNumber - 1 + const currentLine = buffer.getLine(currentY) + if (!currentLine) { + return [] + } + const maxRows = maxEdgeWrappedHttpRows(currentLine.length) + + const candidates: WrappedLogicalLine[] = [] + const translatedLines = new Map() + const getTranslatedLine = (rowY: number): TrimmedTranslatedLine | null => { + if (translatedLines.has(rowY)) { + return translatedLines.get(rowY) ?? null + } + const line = buffer.getLine(rowY) + const translated = line ? trimRightTranslatedLine(line) : null + translatedLines.set(rowY, translated) + return translated + } + const minY = Math.max(0, currentY - maxRows + 1) + for (let startY = currentY; startY >= minY; startY--) { + const start = getTranslatedLine(startY) + const schemeIndex = start ? findHttpSchemeIndex(start.text) : -1 + if (!start || schemeIndex === -1 || VERTICAL_LAYOUT_FRAME_PATTERN.test(start.text)) { + continue + } + + let text = '' + const rows: WrappedLogicalLine['rows'] = [] + for (let rowY = startY; rowY < startY + maxRows; rowY++) { + const line = buffer.getLine(rowY) + const translated = getTranslatedLine(rowY) + if (!line || !translated) { + break + } + if (VERTICAL_LAYOUT_FRAME_PATTERN.test(translated.text)) { + break + } + if ( + rows.length > 0 && + !line.isWrapped && + !rowsCanContinueAtTerminalEdge(rows.at(-1)!, translated) + ) { + break + } + const fragmentStartIndex = rowY === startY ? schemeIndex : 0 + const fragment = translated.text.slice(fragmentStartIndex) + const whitespaceIndex = fragment.search(/\s/) + const possibleUrlFragmentLength = whitespaceIndex === -1 ? fragment.length : whitespaceIndex + if (text.length + possibleUrlFragmentLength > TERMINAL_HTTP_URL_MAX_LENGTH) { + break + } + + rows.push({ + y: rowY, + text: fragment, + sourceText: translated.sourceText, + columns: translated.columns.slice(fragmentStartIndex), + startIndex: text.length, + isWrapped: line.isWrapped, + lineLength: line.length + }) + text += fragment + if (rows.length > 1 && rowY >= currentY) { + candidates.push({ + text, + rows: [...rows], + fingerprint: `edge-http:${rows.map((row) => `${row.y}:${row.sourceText}`).join('\0')}` + }) + } + } + } + + return candidates.sort( + (left, right) => right.rows.length - left.rows.length || right.text.length - left.text.length + ) +} diff --git a/src/renderer/src/components/terminal-pane/repro-8832-url-next-line.test.ts b/src/renderer/src/components/terminal-pane/repro-8832-url-next-line.test.ts new file mode 100644 index 00000000000..14fbe14aa81 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/repro-8832-url-next-line.test.ts @@ -0,0 +1,203 @@ +/** + * Issue #8832 — Cmd-click URL must not glue the next logical line. + * + * Root cause: path hard-wrap reconstruction (from #8339) still joins a URL + * suffix ending in `/` with the next row's path-like prefix. HTTP hit-testing + * must not consume those candidates; it uses soft-wrap, framed hard-wrap HTTP, + * and edge-wrap reconstruction only. + * + * Re-run: + * pnpm exec vitest run --config config/vitest.config.ts \ + * src/renderer/src/components/terminal-pane/repro-8832-url-next-line.test.ts + */ +import type { IBufferLine } from '@xterm/xterm' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { registerHttpLinkStoreAccessor } from '@/lib/http-link-routing' +import { buildCandidateLogicalLinesForBufferPosition } from './terminal-file-link-hit-testing' +import { + extractTerminalHttpLinks, + openHttpLinkAtBufferPosition +} from './terminal-url-link-hit-testing' +import { buildHardWrappedPathLogicalLineCandidates } from './wrapped-terminal-link-ranges' + +const LINE_1 = 'Repo: https://github.com/stablyai/orca/' +const LINE_2 = 'Description: 123' +const EXPECTED_URL = 'https://github.com/stablyai/orca/' +const BUGGY_URL = 'https://github.com/stablyai/orca/Description' + +const openUrlMock = vi.fn() + +function makeBufferLine( + text: string, + options: { cols?: number; isWrapped?: boolean } = {} +): IBufferLine { + const cols = options.cols ?? Math.max(text.length, 80) + const padded = text.padEnd(cols) + return { + isWrapped: options.isWrapped ?? false, + length: cols, + getCell: () => undefined, + translateToString: ( + _trimRight?: boolean, + startColumn = 0, + endColumn = padded.length, + outColumns?: number[] + ) => { + if (outColumns) { + outColumns.length = 0 + for (let index = startColumn; index <= endColumn; index++) { + outColumns.push(index) + } + } + return padded.slice(startColumn, endColumn) + } + } as IBufferLine +} + +function twoRowBuffer( + row0: string, + row1: string, + options: { cols?: number; softWrapped?: boolean } = {} +): { getLine(y: number): IBufferLine | undefined } { + const cols = options.cols ?? 120 + const rows = [ + makeBufferLine(row0, { cols, isWrapped: false }), + makeBufferLine(row1, { cols, isWrapped: options.softWrapped === true }) + ] + return { getLine: (y: number) => rows[y] } +} + +function issueBuffer(): { getLine(y: number): IBufferLine | undefined } { + return twoRowBuffer(LINE_1, LINE_2, { cols: 120 }) +} + +function openUrlAt( + buffer: { getLine(y: number): IBufferLine | undefined }, + x: number, + y: number, + cols = 120 +) { + openUrlMock.mockReset() + const opened = openHttpLinkAtBufferPosition(buffer, { x, y }, cols, { + worktreeId: 'wt-repro-8832', + forceSystemBrowser: true + }) + return { opened, url: openUrlMock.mock.calls[0]?.[0] as string | undefined } +} + +describe('#8832 hard-wrapped path candidates glue next-line text into URLs', () => { + beforeEach(() => { + vi.stubGlobal('navigator', { userAgent: 'Macintosh' }) + vi.stubGlobal('window', { api: { shell: { openUrl: openUrlMock } } }) + registerHttpLinkStoreAccessor(() => ({ + settings: { openLinksInApp: false }, + setActiveWorktree: vi.fn(), + createBrowserTab: vi.fn() + })) + openUrlMock.mockReset() + }) + + afterEach(() => { + vi.unstubAllGlobals() + }) + + it('still builds a multi-row path candidate that concatenates Description onto the URL', () => { + // Why: documents the path-reconstruction poison that HTTP hit-testing must + // not consume. File links remain protected by existence checks. + const buffer = issueBuffer() + const candidates = buildHardWrappedPathLogicalLineCandidates(buffer, 1) + const multiRow = candidates.filter((candidate) => candidate.rows.length > 1) + + expect(multiRow.some((candidate) => candidate.text.includes('Description'))).toBe(true) + + const glued = multiRow.find((candidate) => candidate.text.includes('https://')) + expect(glued).toBeDefined() + expect(glued!.text).toContain('https://github.com/stablyai/orca/Description') + }) + + it('HTTP extraction on a path logical line still yields the glued URL', () => { + const buffer = issueBuffer() + const candidates = buildCandidateLogicalLinesForBufferPosition(buffer, 1) + const extracted = candidates.flatMap((line) => extractTerminalHttpLinks(line.text)) + + expect(extracted.map((link) => link.url)).toContain(BUGGY_URL) + const singleLineUrls = extractTerminalHttpLinks(LINE_1).map((link) => link.url) + expect(singleLineUrls).toEqual([EXPECTED_URL]) + }) + + it('openHttpLinkAtBufferPosition opens the unglued URL when Cmd-clicking the URL row', () => { + const buffer = issueBuffer() + const urlStart = LINE_1.indexOf('https://') + const { opened, url } = openUrlAt(buffer, urlStart + 10, 1) + + expect(opened).toBe(true) + expect(url).toBe(EXPECTED_URL) + expect(url).not.toBe(BUGGY_URL) + }) + + it.each([ + ['Chinese label', '说明: 中文路径/文件.ts'], + ['Windows path', 'C:\\Users\\demo\\project\\README.md'], + ['POSIX path', '/usr/local/bin/orca'], + ['relative path', './src/main/index.ts'], + ['UNC-ish path', '\\\\server\\share\\file.txt'] + ])('does not glue a mid-row URL to a next-line %s', (_label, nextLine) => { + const row0 = 'See https://example.com/docs/' + const buffer = twoRowBuffer(row0, nextLine, { cols: 100 }) + const urlStart = row0.indexOf('https://') + const { opened, url } = openUrlAt(buffer, urlStart + 8, 1) + + expect(opened).toBe(true) + expect(url).toBe('https://example.com/docs/') + expect(url).not.toMatch(/说明|Users|usr|src|server/) + }) + + it('still joins a soft-wrapped multi-line URL via native wrap metadata', () => { + const cols = 40 + const url = 'https://example.com/very/long/path/segments/that/continue/more' + const row0 = url.slice(0, cols) + const row1 = url.slice(cols) + const buffer = twoRowBuffer(row0, row1, { cols, softWrapped: true }) + const first = openUrlAt(buffer, 11, 1, cols) + const second = openUrlAt(buffer, 5, 2, cols) + + expect(first.opened).toBe(true) + expect(first.url).toBe(url) + expect(second.opened).toBe(true) + expect(second.url).toBe(url) + }) + + it('still joins an edge-wrapped multi-line URL without wrap metadata', () => { + const cols = 40 + const url = 'https://example.com/very/long/path/segments/that/continue/more' + const row0 = url.slice(0, cols) + const row1 = url.slice(cols) + expect(row0).toHaveLength(cols) + const buffer = twoRowBuffer(row0, row1, { cols, softWrapped: false }) + const first = openUrlAt(buffer, 11, 1, cols) + const second = openUrlAt(buffer, 5, 2, cols) + + expect(first.opened).toBe(true) + expect(first.url).toBe(url) + expect(second.opened).toBe(true) + expect(second.url).toBe(url) + }) + + it('does not treat file:// as an HTTP link (file routing stays separate)', () => { + const row0 = 'open file:///Users/demo/project/README.md' + const buffer = twoRowBuffer(row0, 'next: line', { cols: 100 }) + const { opened, url } = openUrlAt(buffer, row0.indexOf('file://') + 4, 1) + + expect(opened).toBe(false) + expect(url).toBeUndefined() + }) + + it('opens only the HTTP URL when a Windows path shares the same row', () => { + const row0 = 'https://example.com/a C:\\Users\\demo\\file.txt' + const buffer = twoRowBuffer(row0, 'Description: more', { cols: 100 }) + const { opened, url } = openUrlAt(buffer, 8, 1) + + expect(opened).toBe(true) + expect(url).toBe('https://example.com/a') + }) +}) diff --git a/src/renderer/src/components/terminal-pane/terminal-url-link-click.test.ts b/src/renderer/src/components/terminal-pane/terminal-url-link-click.test.ts index c8e85950da3..36b38c09a5e 100644 --- a/src/renderer/src/components/terminal-pane/terminal-url-link-click.test.ts +++ b/src/renderer/src/components/terminal-pane/terminal-url-link-click.test.ts @@ -379,6 +379,44 @@ describe('hard-wrapped terminal HTTP clicks', () => { expect(openUrlMock).toHaveBeenCalledWith('http://example.com/') }) + it('does not glue the next logical line onto a URL that ends mid-row (#8832)', () => { + const { terminal, registrations } = makeTerminal({ + cols: 80, + urlRows: ['Repo: https://github.com/stablyai/orca/', 'Description: 123'], + softWrapped: false + }) + const disposable = installHttpLinkClickFallback(terminal, { worktreeId: 'wt-1' }) + + const fallback = registrations.find( + ([name, _listener, options]) => name === 'mouseup' && options === undefined + )?.[1] + fallback!(mouseEventForRow(0)) + + expect(openUrlMock).toHaveBeenCalledOnce() + expect(openUrlMock).toHaveBeenCalledWith('https://github.com/stablyai/orca/') + disposable.dispose() + }) + + it('still joins a URL hard-wrapped at the row edge without native wrap metadata', () => { + const cols = 40 + const url = 'https://example.com/very/long/path/segments/that/continue/more' + const { terminal, registrations } = makeTerminal({ + cols, + urlRows: [url.slice(0, cols), url.slice(cols)], + softWrapped: false + }) + const disposable = installHttpLinkClickFallback(terminal, { worktreeId: 'wt-1' }) + + const fallback = registrations.find( + ([name, _listener, options]) => name === 'mouseup' && options === undefined + )?.[1] + fallback!(mouseEventForRow(0)) + + expect(openUrlMock).toHaveBeenCalledOnce() + expect(openUrlMock).toHaveBeenCalledWith(url) + disposable.dispose() + }) + it('does not suppress a modifier-click when the buffer position is not an HTTP link', () => { const { terminal, registrations } = makeTerminal({ urlRows: ['not-a-link'] }) const disposable = installHttpLinkClickFallback(terminal, { worktreeId: 'wt-1' }) diff --git a/src/renderer/src/components/terminal-pane/terminal-url-link-hit-testing.ts b/src/renderer/src/components/terminal-pane/terminal-url-link-hit-testing.ts index bfbbe29f723..3a6e2c1f274 100644 --- a/src/renderer/src/components/terminal-pane/terminal-url-link-hit-testing.ts +++ b/src/renderer/src/components/terminal-pane/terminal-url-link-hit-testing.ts @@ -1,10 +1,8 @@ import type { IBufferLine, IBufferRange, IDisposable, Terminal } from '@xterm/xterm' import { openHttpLink } from '@/lib/http-link-routing' -import { - buildCandidateLogicalLinesForBufferPosition, - dedupeLogicalLines -} from './terminal-file-link-hit-testing' +import { buildEdgeWrappedHttpLogicalLineCandidates } from './edge-wrapped-terminal-http-links' import { buildHardWrappedHttpLogicalLineCandidates } from './hard-wrapped-terminal-http-links' +import { dedupeLogicalLines } from './terminal-file-link-hit-testing' import { isTerminalHttpLinkActivation } from './terminal-http-link-activation' import { installTerminalLinkPtyMouseSuppression } from './terminal-link-pty-mouse-suppression' import { getTerminalBufferPositionForMouseEvent } from './terminal-mouse-buffer-position' @@ -271,7 +269,10 @@ function findHttpLinkAtBufferPosition( ? [nativeWrappedLogicalLine] : []), ...buildHardWrappedHttpLogicalLineCandidates(buffer, position.y), - ...buildCandidateLogicalLinesForBufferPosition(buffer, position.y) + ...buildEdgeWrappedHttpLogicalLineCandidates(buffer, position.y), + ...(nativeWrappedLogicalLine && nativeWrappedLogicalLine.rows.length === 1 + ? [nativeWrappedLogicalLine] + : []) ]) if (logicalLines.length === 0) { return null @@ -290,6 +291,17 @@ function findHttpLinkAtBufferPosition( return null } +function rangeContainsBufferPosition( + range: IBufferRange, + position: { x: number; y: number }, + terminalColumns: number +): boolean { + const lower = range.start.y * terminalColumns + range.start.x + const upper = range.end.y * terminalColumns + range.end.x + const current = position.y * terminalColumns + position.x + return lower <= current && current <= upper +} + export function openTerminalHttpLink(url: string, deps: UrlLinkHitTestDeps): void { if (deps.forceSystemBrowser) { openHttpLink(url, { worktreeId: deps.worktreeId, forceSystemBrowser: true }) @@ -316,14 +328,3 @@ export function openTerminalHttpLink(url: string, deps: UrlLinkHitTestDeps): voi openHttpLink(url, { worktreeId: deps.worktreeId, forceSystemBrowser: true }) }) } - -function rangeContainsBufferPosition( - range: IBufferRange, - position: { x: number; y: number }, - terminalColumns: number -): boolean { - const lower = range.start.y * terminalColumns + range.start.x - const upper = range.end.y * terminalColumns + range.end.x - const current = position.y * terminalColumns + position.x - return lower <= current && current <= upper -} From 539e0601d5f14b350a8ca20c408326058efa05b3 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Thu, 16 Jul 2026 19:24:10 -0700 Subject: [PATCH 15/24] fix(github): render GitHub Enterprise PR avatars via API avatar_url (#9107) Co-authored-by: Claude Opus 4.8 (1M context) Co-authored-by: davkim1030 --- src/main/github/client.ts | 35 +++-- src/main/github/work-item-details.test.ts | 112 ++++++++++++++ src/main/github/work-item-details.ts | 138 ++++++++++++++++-- .../src/components/GitHubItemDialog.tsx | 13 +- .../src/components/PullRequestPage.tsx | 23 +-- src/renderer/src/components/TaskPage.tsx | 18 +-- .../github/github-user-avatar.test.tsx | 107 ++++++++++++++ .../components/github/github-user-avatar.tsx | 104 +++++++++++++ .../repro-8784-ghe-avatar-fallback.test.ts | 55 +++++++ src/shared/types.ts | 5 + 10 files changed, 547 insertions(+), 63 deletions(-) create mode 100644 src/renderer/src/components/github/github-user-avatar.test.tsx create mode 100644 src/renderer/src/components/github/github-user-avatar.tsx create mode 100644 src/renderer/src/components/github/repro-8784-ghe-avatar-fallback.test.ts diff --git a/src/main/github/client.ts b/src/main/github/client.ts index 54572bd406f..ac85669836a 100644 --- a/src/main/github/client.ts +++ b/src/main/github/client.ts @@ -485,16 +485,32 @@ function mapIssueWorkItem(item: Record): MainWorkItem { .filter(Boolean) : [], updatedAt: String(item.updated_at ?? item.updatedAt ?? ''), - author: - typeof item.user === 'object' && item.user !== null && 'login' in item.user - ? String((item.user as { login?: unknown }).login ?? '') - : typeof item.author === 'object' && item.author !== null && 'login' in item.author - ? String((item.author as { login?: unknown }).login ?? '') - : null, + ...authorFieldsFromUnknown(item), ...(item.assignees !== undefined ? { assignees: usersFromUnknown(item.assignees) } : {}) } } +/** + * Derive both the author login and its API avatar_url in one place so GHE + * avatars render (the login-only `github.com/{login}.png` URL 404s on GHE). + * + * REST exposes `user.avatar_url`; gh/GraphQL expose `author.avatarUrl`. `gh pr + * view` omits the avatar entirely, so authorAvatarUrl is left undefined and the + * UI falls back to the login URL then a placeholder. See #8784. + */ +function authorFieldsFromUnknown( + item: Record +): Pick { + const user = userFromUnknown(item.user ?? item.author) + if (!user) { + return { author: null } + } + return { + author: user.login, + ...(user.avatarUrl ? { authorAvatarUrl: user.avatarUrl } : {}) + } +} + function extractHeadOwnerLogin(item: Record): string | null { // gh CLI `pr list --json headRepositoryOwner` shape: { login } if (typeof item.headRepositoryOwner === 'object' && item.headRepositoryOwner !== null) { @@ -746,12 +762,7 @@ function mapPullRequestWorkItem( .filter(Boolean) : [], updatedAt: String(item.updated_at ?? item.updatedAt ?? ''), - author: - typeof item.user === 'object' && item.user !== null && 'login' in item.user - ? String((item.user as { login?: unknown }).login ?? '') - : typeof item.author === 'object' && item.author !== null && 'login' in item.author - ? String((item.author as { login?: unknown }).login ?? '') - : null, + ...authorFieldsFromUnknown(item), branchName: typeof item.head === 'object' && item.head !== null && 'ref' in item.head ? String((item.head as { ref?: unknown }).ref ?? '') diff --git a/src/main/github/work-item-details.test.ts b/src/main/github/work-item-details.test.ts index 6d3d14c99b7..4fd21131c23 100644 --- a/src/main/github/work-item-details.test.ts +++ b/src/main/github/work-item-details.test.ts @@ -216,6 +216,56 @@ describe('getWorkItemDetails', () => { expect(details?.participants?.[0]?.login).toBe('issue-author') }) + it('enriches a non-participating assignee avatar from the GraphQL assignees connection', async () => { + // Why: a GHE assignee who never commented is absent from `participants`, so + // the enrichment must also draw avatars from the `assignees` connection or + // item.assignees keeps the blank avatar `gh` returns. + getWorkItemMock.mockResolvedValueOnce({ + id: 'issue:924', + type: 'issue', + number: 924, + title: 'Assignee avatar', + state: 'open', + url: 'https://github.com/acme/widgets/issues/924', + labels: [], + updatedAt: '2026-04-01T00:00:00Z', + author: 'issue-author', + assignees: [{ login: 'ghe-assignee', name: 'GHE Assignee', avatarUrl: '' }] + }) + getIssueOwnerRepoMock.mockResolvedValue({ owner: 'acme', repo: 'widgets' }) + ghExecFileAsyncMock + .mockResolvedValueOnce({ + stdout: JSON.stringify({ + data: { + repository: { + issue: { + body: 'Issue body', + assignees: { + nodes: [ + { + login: 'ghe-assignee', + name: 'GHE Assignee', + avatarUrl: 'https://ghe.example.com/avatars/ghe-assignee' + } + ] + }, + participants: { nodes: [{ login: 'issue-author', avatarUrl: 'https://x/y' }] }, + comments: { nodes: [] } + } + } + } + }) + }) + .mockResolvedValueOnce({ stdout: '' }) + + const details = await getWorkItemDetails('/repo-root', 924, 'issue') + + expect(details?.item.assignees?.[0]).toMatchObject({ + login: 'ghe-assignee', + avatarUrl: 'https://ghe.example.com/avatars/ghe-assignee' + }) + }) + it('caps issue timeline pagination by supported activity items', async () => { getWorkItemMock.mockResolvedValueOnce({ id: 'issue:923', @@ -568,4 +618,66 @@ describe('getWorkItemDetails', () => { expect(details?.filesUnavailable).toBe(false) expect(details?.files).toEqual([]) }) + + // Why: `gh pr view` omits avatar_url, so the login-based github.com URL 404s on + // GHE. getWorkItemDetails must resolve author/reviewer/assignee avatars via the + // GraphQL user(login:) batch and stamp them onto the returned item. See #8784. + it('enriches PR author, reviewer, and assignee avatars from the GraphQL user lookup', async () => { + getWorkItemMock.mockResolvedValueOnce({ + id: 'pr:1102', + type: 'pr', + number: 1102, + title: 'Enterprise PR', + state: 'open', + url: 'https://ghe.example.com/acme/widgets/pull/1102', + labels: [], + updatedAt: '2026-07-11T00:00:00Z', + author: 'seah', + reviewRequests: [{ login: 'ludi', name: null, avatarUrl: '' }], + latestReviews: [{ login: 'inho', state: 'APPROVED', avatarUrl: '' }], + // A default `u/0` placeholder must be replaced by the resolved avatar. + assignees: [{ login: 'seah', name: 'Seah', avatarUrl: 'https://avatars.example.com/u/0?v=4' }] + }) + getOwnerRepoMock.mockResolvedValue({ owner: 'acme', repo: 'widgets' }) + getPRCommentsMock.mockResolvedValue([]) + getPRChecksMock.mockResolvedValue([]) + const avatars: Record = { + seah: 'https://avatars.example.com/u/1?v=4', + ludi: 'https://avatars.example.com/u/2?v=4', + inho: 'https://avatars.example.com/u/3?v=4' + } + ghExecFileAsyncMock.mockImplementation(async (args: string[]) => { + const target = args.at(-1) + if (target === 'repos/acme/widgets/pulls/1102') { + return { + stdout: JSON.stringify({ body: 'PR body', head: { sha: 'h' }, base: { sha: 'b' } }) + } + } + if (target === 'repos/acme/widgets/pulls/1102/files?per_page=100') { + return { stdout: '[]' } + } + const query = args.find((arg) => arg.startsWith('query=')) ?? '' + if (query.includes('user(login:')) { + // Return the aliased users the batch asked for, keyed by their login. + const data: Record = {} + let index = 0 + for (const login of Object.keys(avatars)) { + if (query.includes(`user(login: "${login}")`)) { + data[`u${index}`] = { login, name: null, avatarUrl: avatars[login] } + index += 1 + } + } + return { stdout: JSON.stringify({ data }) } + } + return { stdout: JSON.stringify({ data: {} }) } + }) + + const details = await getWorkItemDetails('/repo-root', 1102, 'pr') + + expect(details?.item.authorAvatarUrl).toBe(avatars.seah) + expect(details?.item.reviewRequests?.[0]?.avatarUrl).toBe(avatars.ludi) + expect(details?.item.latestReviews?.[0]?.avatarUrl).toBe(avatars.inho) + // The default u/0 placeholder is overridden by the resolved avatar. + expect(details?.item.assignees?.[0]?.avatarUrl).toBe(avatars.seah) + }) }) diff --git a/src/main/github/work-item-details.ts b/src/main/github/work-item-details.ts index 9aa2de2b26b..bd126641fcb 100644 --- a/src/main/github/work-item-details.ts +++ b/src/main/github/work-item-details.ts @@ -85,7 +85,7 @@ const ISSUE_DETAILS_QUERY = `query($owner: String!, $repo: String!, $number: Int repository(owner: $owner, name: $repo) { issue(number: $number) { body - assignees(first: 50) { nodes { login } } + assignees(first: 50) { nodes { login avatarUrl(size: 48) ... on User { name } } } participants(first: 100) { nodes { login avatarUrl(size: 48) ... on User { name } } } @@ -111,7 +111,7 @@ type GraphQLIssueDetailsResponse = { repository?: { issue?: { body?: string | null - assignees?: { nodes?: { login?: string }[] } + assignees?: { nodes?: { login?: string; avatarUrl?: string; name?: string | null }[] } participants?: { nodes?: GitHubAssignableUser[] } comments?: { nodes?: { @@ -323,6 +323,12 @@ async function getIssueTimelineItems( } } +/** + * Fetch an issue's body, comments, assignees, participants, and timeline in a + * single GraphQL round-trip. Returns null on any partial error so the caller + * falls back to the strict REST path. Assignee/participant avatars are resolved + * here so GitHub Enterprise users don't render blank. + */ async function getIssueDetailsViaGraphQL( repoPath: string, issueNumber: number, @@ -332,6 +338,10 @@ async function getIssueDetailsViaGraphQL( body: string comments: PRComment[] assignees: string[] + // Avatar-bearing assignees, kept separate from the `assignees` login list so + // callers that only need logins are unaffected; used to enrich assignee + // avatars that `gh` leaves blank (notably GitHub Enterprise users). + assigneeUsers: GitHubAssignableUser[] participants: GitHubAssignableUser[] timelineItems: GitHubIssueTimelineItem[] } | null> { @@ -386,9 +396,16 @@ async function getIssueDetailsViaGraphQL( url: c.url ?? '', isBot: c.author?.__typename === 'Bot' })) - const assignees = (issue.assignees?.nodes ?? []) - .map((a) => a.login) - .filter((login): login is string => Boolean(login)) + const assigneeUsers: GitHubAssignableUser[] = (issue.assignees?.nodes ?? []) + .filter((a): a is { login: string; avatarUrl?: string; name?: string | null } => + Boolean(a.login) + ) + .map((a) => ({ + login: a.login, + name: a.name ?? null, + avatarUrl: a.avatarUrl ?? '' + })) + const assignees = assigneeUsers.map((a) => a.login) const participants: GitHubAssignableUser[] = (issue.participants?.nodes ?? []) .filter((u) => Boolean(u.login)) .map((u) => ({ @@ -401,6 +418,7 @@ async function getIssueDetailsViaGraphQL( body: issue.body ?? '', comments, assignees, + assigneeUsers, participants, timelineItems } @@ -877,6 +895,11 @@ async function getGitHubUsersByLogin( return [] } if (rateLimitGuard('graphql').blocked) { + // Why: surface the skip. Callers degrade silently to login-based avatar URLs + // (blank on GHE), so without this log "why are avatars missing" is untraceable. + console.warn( + `getGitHubUsersByLogin skipped: GraphQL rate-limit budget exhausted (${uniqueLogins.length} logins unresolved)` + ) return [] } const fields = uniqueLogins @@ -921,19 +944,100 @@ async function getGitHubUsersByLogin( } } +/** + * Stamp GraphQL-resolved avatars onto a work item's author, reviewers, review + * requests, latest reviews, and assignees. + * + * The work item's users come from `gh pr view`, which omits avatar_url. On + * GitHub Enterprise the login-based `github.com/{login}.png` URL 404s, so those + * avatars render blank while comment avatars (fetched via GraphQL) work. + * `knownUsers` (participants + the mention-author batch, which already includes + * the item's reviewers/assignees) carries the resolved avatars, so we apply + * them without any extra round-trip. See #8784. + */ +function enrichItemDisplayAvatars( + item: Omit, + knownUsers: GitHubAssignableUser[] +): Omit { + const avatarByLogin = new Map() + for (const user of knownUsers) { + if (user.login && user.avatarUrl) { + avatarByLogin.set(user.login.toLowerCase(), user.avatarUrl) + } + } + if (avatarByLogin.size === 0) { + return item + } + // Why: prefer the GraphQL-resolved avatar over whatever `gh` returned. `gh pr + // view` often yields an empty avatar or the default `u/0` placeholder for + // enterprise users; the resolved avatar is the authoritative one for that + // login. Fall back to the original only when the lookup found nothing. + const avatarFor = (login: string): string | undefined => avatarByLogin.get(login.toLowerCase()) + const resolvedAvatar = (login: string, existing?: string | null): string | undefined => + avatarFor(login) || existing || undefined + // Callers coalesce a missing result to the field's "no avatar" sentinel ('' + // for GitHubAssignableUser, null for GitHubPRReviewSummary); GitHubUserAvatar + // treats both as falsy and falls back to the login URL then initials. + const authorAvatarUrl = (item.author ? avatarFor(item.author) : undefined) || item.authorAvatarUrl + return { + ...item, + ...(authorAvatarUrl ? { authorAvatarUrl } : {}), + ...(item.reviewRequests + ? { + reviewRequests: item.reviewRequests.map((user) => ({ + ...user, + avatarUrl: resolvedAvatar(user.login, user.avatarUrl) ?? '' + })) + } + : {}), + ...(item.latestReviews + ? { + latestReviews: item.latestReviews.map((review) => ({ + ...review, + avatarUrl: resolvedAvatar(review.login, review.avatarUrl) ?? null + })) + } + : {}), + ...(item.assignees + ? { + assignees: item.assignees.map((user) => ({ + ...user, + avatarUrl: resolvedAvatar(user.login, user.avatarUrl) ?? '' + })) + } + : {}) + } +} + async function getMentionParticipants( repoPath: string, - item: Pick, + item: Pick< + GitHubWorkItem, + 'author' | 'number' | 'type' | 'reviewRequests' | 'latestReviews' | 'assignees' + >, comments: PRComment[], participants: GitHubAssignableUser[], connectionId?: string | null, localGitOptions: LocalGitExecOptions = {} ): Promise { - const visibleLogins = [item.author ?? '', ...comments.map((comment) => comment.author)] - // Why: one aliased GraphQL query returns login/name/avatarUrl for every - // mentioned author in a single round-trip. The previous REST fan-out - // (/users/) returned the same fields but cost one rate-limit point - // per user. + // Why: resolve mention authors AND the item's reviewers/assignees in ONE + // aliased GraphQL round-trip. Folding the display users in here means the + // caller can reuse this result to stamp avatars (see enrichItemDisplayAvatars) + // without a second, separately rate-limited lookup — which is what made GHE + // reviewer/assignee avatars resolve intermittently. See #8784. + // Why: order the always-visible display users (author, reviewers, assignees) + // before comment authors so the 40-login cap in getGitHubUsersByLogin never + // drops a reviewer/assignee avatar on a PR with many commenters. On a PR with + // 40+ distinct participants the trailing comment authors are dropped from this + // batch and lose only their @mention-suggestion avatar; rendered comment + // avatars are unaffected (they carry their own avatarUrl from the comment). + const visibleLogins = [ + item.author ?? '', + ...(item.reviewRequests ?? []).map((user) => user.login), + ...(item.latestReviews ?? []).map((review) => review.login), + ...(item.assignees ?? []).map((user) => user.login), + ...comments.map((comment) => comment.author) + ] const graphQlUsers = await getGitHubUsersByLogin( repoPath, visibleLogins, @@ -1007,7 +1111,13 @@ export async function getWorkItemDetails( ) if (collapsed) { return { - item, + // Include assigneeUsers: a non-participating assignee is absent from + // the participants connection, so without them item.assignees keeps a + // blank/placeholder avatar (common for GitHub Enterprise users). + item: enrichItemDisplayAvatars(item, [ + ...collapsed.participants, + ...collapsed.assigneeUsers + ]), body: collapsed.body, comments: collapsed.comments, assignees: collapsed.assignees, @@ -1030,7 +1140,7 @@ export async function getWorkItemDetails( localGitOptions ) return { - item, + item: enrichItemDisplayAvatars(item, mentionParticipants), body, comments, assignees, @@ -1064,7 +1174,7 @@ export async function getWorkItemDetails( ]) return { - item, + item: enrichItemDisplayAvatars(item, mentionParticipants), body, comments, headSha: shas?.headSha, diff --git a/src/renderer/src/components/GitHubItemDialog.tsx b/src/renderer/src/components/GitHubItemDialog.tsx index d076f0b325d..6f6b7fb2424 100644 --- a/src/renderer/src/components/GitHubItemDialog.tsx +++ b/src/renderer/src/components/GitHubItemDialog.tsx @@ -181,7 +181,7 @@ import { filterGitHubPRReviewerCandidates, getGitHubPRReviewerQueryState } from '@/components/github/github-pr-reviewer-candidate-filter' -import { githubAvatarUrl } from '@/components/github/github-issue-comment-helpers' +import { GitHubUserAvatar } from '@/components/github/github-user-avatar' import { presentGitHubPRMergeState } from '@/components/github-pr-merge-state' import { GITHUB_PR_MERGE_METHOD_LABELS, @@ -421,16 +421,7 @@ function ReviewerAvatar({ login: string avatarUrl: string }): React.JSX.Element { - return ( - - ) + return } function mergeReviewerSuggestions( diff --git a/src/renderer/src/components/PullRequestPage.tsx b/src/renderer/src/components/PullRequestPage.tsx index d807dacf89f..b53ba354618 100644 --- a/src/renderer/src/components/PullRequestPage.tsx +++ b/src/renderer/src/components/PullRequestPage.tsx @@ -157,7 +157,7 @@ import { filterGitHubPRReviewerCandidates, getGitHubPRReviewerQueryState } from '@/components/github/github-pr-reviewer-candidate-filter' -import { githubAvatarUrl } from '@/components/github/github-issue-comment-helpers' +import { GitHubUserAvatar } from '@/components/github/github-user-avatar' import { filterGitHubMentionOptions } from '@/components/github/github-mention-option-filter' import { getCommentBodySubmitState, @@ -468,16 +468,7 @@ function ReviewerAvatar({ login: string avatarUrl: string }): React.JSX.Element { - return ( - - ) + return } function mergeReviewerSuggestions( @@ -7148,12 +7139,10 @@ export default function PullRequestPage({ {workItem.author ? ( - ) : null} diff --git a/src/renderer/src/components/TaskPage.tsx b/src/renderer/src/components/TaskPage.tsx index af47d26736d..e43cc6a8089 100644 --- a/src/renderer/src/components/TaskPage.tsx +++ b/src/renderer/src/components/TaskPage.tsx @@ -133,6 +133,7 @@ import { } from '../../../shared/linear-links' import PRFilterDropdowns, { type PRFilterChange } from '@/components/github/PRFilterDropdowns' import { GitHubMarkdownComposer } from '@/components/github/GitHubMarkdownComposer' +import { GitHubUserAvatar } from '@/components/github/github-user-avatar' import { buildGitHubRepoUrl, parseGitHubIssueOrPRLink } from '@/lib/github-links' import { findGithubWorkItemWorkspaceAttachment, @@ -1520,17 +1521,16 @@ function ReviewChipAvatar({ reviewer: GitHubPRPrimaryReviewer | null }): React.JSX.Element { if (reviewer?.login) { - // Why: `gh pr list --json reviewRequests` can return only logins; GitHub's - // public avatar endpoint keeps the list visual aligned with assignee cells. - const avatarUrl = reviewer.avatarUrl || `https://github.com/${reviewer.login}.png?size=40` + // Why: `gh pr list --json reviewRequests` can return only logins. Prefer the + // API avatar_url so GHE renders; GitHubUserAvatar falls back to the login URL + // and then an initials placeholder when no avatar loads. See #8784. return ( - ) } diff --git a/src/renderer/src/components/github/github-user-avatar.test.tsx b/src/renderer/src/components/github/github-user-avatar.test.tsx new file mode 100644 index 00000000000..5eeed156426 --- /dev/null +++ b/src/renderer/src/components/github/github-user-avatar.test.tsx @@ -0,0 +1,107 @@ +// @vitest-environment happy-dom + +import { act } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, describe, expect, it } from 'vitest' +import { GitHubUserAvatar, resolveGitHubUserAvatarSrc } from './github-user-avatar' + +function render(node: React.JSX.Element): { root: Root; container: HTMLDivElement } { + const container = document.createElement('div') + document.body.appendChild(container) + const root = createRoot(container) + act(() => { + root.render(node) + }) + return { root, container } +} + +describe('GitHubUserAvatar', () => { + let root: Root | null = null + let container: HTMLDivElement | null = null + + afterEach(() => { + act(() => root?.unmount()) + container?.remove() + root = null + container = null + }) + + it('renders the API avatar_url when provided (works for github.com and GHE)', () => { + const url = 'https://avatars.example.com/u/42?u=hash&v=4' + ;({ root, container } = render()) + const img = container.querySelector('img') + expect(img?.getAttribute('src')).toBe(url) + }) + + it('falls back to the login URL when no avatar_url is available', () => { + ;({ root, container } = render()) + const img = container.querySelector('img') + expect(img?.getAttribute('src')).toBe('https://github.com/octocat.png?size=64') + }) + + it('degrades to an initials placeholder when the image fails to load (GHE 404)', () => { + ;({ root, container } = render( + + )) + const img = container.querySelector('img') + expect(img).not.toBeNull() + act(() => { + img?.dispatchEvent(new Event('error')) + }) + expect(container.querySelector('img')).toBeNull() + expect(container.textContent).toBe('AL') + }) + + it('skips leading non-BMP chars so initials never render a broken surrogate', () => { + ;({ root, container } = render()) + act(() => { + container?.querySelector('img')?.dispatchEvent(new Event('error')) + }) + // Emoji word is skipped entirely rather than split into a broken half. + expect(container.textContent).toBe('D') + }) + + it('derives initials from the login when no name is given', () => { + ;({ root, container } = render()) + act(() => { + container?.querySelector('img')?.dispatchEvent(new Event('error')) + }) + expect(container.textContent).toBe('OC') + }) + + it('retries when avatarUrl changes after an earlier src failed (late-arriving avatar)', () => { + // Why: PR detail enrichment supplies avatar_url after first paint. A row that + // 404s on the early login-based URL must recover once the real URL lands. + ;({ root, container } = render()) + // First src is the login fallback; simulate the GHE 404. + act(() => { + container?.querySelector('img')?.dispatchEvent(new Event('error')) + }) + expect(container.querySelector('img')).toBeNull() + // Enriched avatar_url arrives → component must render the new image, not the + // latched placeholder. + const url = 'https://avatars.example.com/u/7?v=4' + act(() => { + root?.render() + }) + expect(container.querySelector('img')?.getAttribute('src')).toBe(url) + }) + + it('shows initials immediately when login and avatarUrl are both empty', () => { + ;({ root, container } = render()) + expect(container.querySelector('img')).toBeNull() + expect(container.textContent).toBe('AD') + }) +}) + +describe('resolveGitHubUserAvatarSrc', () => { + it('prefers trimmed API avatar over login.png', () => { + expect(resolveGitHubUserAvatarSrc('u', ' https://avatars.example.com/u/1?v=4 ')).toBe( + 'https://avatars.example.com/u/1?v=4' + ) + }) + + it('skips empty login so no github.com/.png request is built', () => { + expect(resolveGitHubUserAvatarSrc('', null)).toBeNull() + }) +}) diff --git a/src/renderer/src/components/github/github-user-avatar.tsx b/src/renderer/src/components/github/github-user-avatar.tsx new file mode 100644 index 00000000000..385c7feda93 --- /dev/null +++ b/src/renderer/src/components/github/github-user-avatar.tsx @@ -0,0 +1,104 @@ +import { useState } from 'react' +import { cn } from '@/lib/utils' +import { githubAvatarUrl } from '@/components/github/github-issue-comment-helpers' + +/** + * Build a 1-2 character initials placeholder from a display name or login, + * used when no avatar image is available or the image fails to load. + */ +function initialsFor(login: string, name?: string | null): string { + const source = (name?.trim() || login).trim() + if (!source) { + return '?' + } + // Iterate by code point (not UTF-16 unit) and keep only letters/digits, so a + // display name that leads with an emoji or other non-BMP char yields clean + // initials instead of a broken surrogate half. Words that are all symbols are + // skipped; an all-symbol name falls back to '?'. + const alnum = (word: string): string[] => [...word].filter((ch) => /[\p{L}\p{N}]/u.test(ch)) + const parts = source.split(/\s+/).filter(Boolean) + const letters = + parts.length >= 2 + ? parts + .map((word) => alnum(word)[0]) + .filter(Boolean) + .slice(0, 2) + .join('') + : alnum(source).slice(0, 2).join('') + return letters.toUpperCase() || '?' +} + +/** + * Prefer the API `avatar_url`, then the public login.png URL. Empty/whitespace + * API values and empty logins never produce a bogus request. See #8784. + */ +export function resolveGitHubUserAvatarSrc( + login: string, + avatarUrl?: string | null +): string | null { + const fromApi = avatarUrl?.trim() + if (fromApi) { + return fromApi + } + const trimmedLogin = login.trim() + if (!trimmedLogin) { + return null + } + return githubAvatarUrl(trimmedLogin) +} + +/** + * Avatar for a GitHub user that renders correctly on github.com and GitHub + * Enterprise. GHE logins don't exist on github.com, so the login-based + * `github.com/{login}.png` URL 404s. We prefer the API-provided `avatarUrl` + * and, when it (or the login fallback) fails to load, degrade to an initials + * placeholder instead of a broken image. See #8784. + */ +export function GitHubUserAvatar({ + login, + name, + avatarUrl, + title, + className +}: { + login: string + name?: string | null + avatarUrl?: string | null + title?: string + className?: string +}): React.JSX.Element { + // Why: track the specific src that failed rather than a boolean. Avatar data + // arrives after the first paint (PR detail enrichment), so a row that 404s on + // the early login-based URL must retry once the real avatar_url lands — a + // latched boolean would keep showing the placeholder forever. See #8784. + const [failedSrc, setFailedSrc] = useState(null) + const src = resolveGitHubUserAvatarSrc(login, avatarUrl) + if (src && failedSrc !== src) { + return ( + setFailedSrc(src)} + className={cn( + 'shrink-0 rounded-full border border-border/50 bg-muted object-cover', + className + )} + /> + ) + } + return ( + + {initialsFor(login, name)} + + ) +} diff --git a/src/renderer/src/components/github/repro-8784-ghe-avatar-fallback.test.ts b/src/renderer/src/components/github/repro-8784-ghe-avatar-fallback.test.ts new file mode 100644 index 00000000000..7b831270910 --- /dev/null +++ b/src/renderer/src/components/github/repro-8784-ghe-avatar-fallback.test.ts @@ -0,0 +1,55 @@ +/** + * Issue #8784 — GHE PR avatars must prefer API avatar_url over github.com/{login}.png. + * + * Regression guard (was a repro that documented the broken path). After the fix: + * - resolveGitHubUserAvatarSrc prefers API URLs + * - PullRequestPage author uses authorAvatarUrl via GitHubUserAvatar + * - TaskPage ReviewChipAvatar no longer hardcodes github.com login.png + * + * Re-run: + * pnpm exec vitest run --config config/vitest.config.ts \ + * src/renderer/src/components/github/repro-8784-ghe-avatar-fallback.test.ts + */ +import { describe, expect, it } from 'vitest' +import { readFileSync } from 'node:fs' +import { join } from 'node:path' +import { githubAvatarUrl } from './github-issue-comment-helpers' +import { resolveGitHubUserAvatarSrc } from './github-user-avatar' + +describe('issue #8784 GHE avatar fallback (regression)', () => { + it('prefers API avatar_url over login.png (GHE healthy path)', () => { + const api = 'https://ghe.example.com/avatars/u/42?v=4' + expect(resolveGitHubUserAvatarSrc('enterprise-only-user', api)).toBe(api) + }) + + it('trims whitespace-only avatarUrl and falls back to login.png for github.com', () => { + expect(resolveGitHubUserAvatarSrc('octocat', ' ')).toBe( + 'https://github.com/octocat.png?size=64' + ) + expect(resolveGitHubUserAvatarSrc('octocat', null)).toBe(githubAvatarUrl('octocat')) + }) + + it('returns null when neither avatarUrl nor login is usable (no bogus request)', () => { + expect(resolveGitHubUserAvatarSrc('', '')).toBeNull() + expect(resolveGitHubUserAvatarSrc(' ', null)).toBeNull() + }) + + it('login-only fallback still hardcodes public github.com png (github.com path)', () => { + // Why: github.com users without avatar_url still use this; GHE relies on + // enrichment + image onError → initials when this 404s. + expect(githubAvatarUrl('corp-user')).toBe('https://github.com/corp-user.png?size=64') + }) + + it('source routes PR author/reviewer avatars through GitHubUserAvatar + authorAvatarUrl', () => { + const prPage = readFileSync(join(__dirname, '../PullRequestPage.tsx'), 'utf8') + expect(prPage).toMatch(/GitHubUserAvatar/) + expect(prPage).toMatch(/authorAvatarUrl/) + // Why: author chip must not ignore API avatar_url and only pass login. + expect(prPage).not.toMatch(/githubAvatarUrl\(workItem\.author\)/) + + const taskPage = readFileSync(join(__dirname, '../TaskPage.tsx'), 'utf8') + expect(taskPage).toMatch(/GitHubUserAvatar/) + // Why: list chip must not hardcode github.com/{login}.png. + expect(taskPage).not.toMatch(/github\.com\/\$\{reviewer\.login\}\.png/) + }) +}) diff --git a/src/shared/types.ts b/src/shared/types.ts index ff46520c44d..36297481741 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -1491,6 +1491,11 @@ export type GitHubWorkItem = { labels: string[] updatedAt: string author: string | null + // Why: GHE user logins don't exist on github.com, so the github.com/{login}.png + // fallback 404s. Carry the API-provided avatar_url so github.com + Enterprise + // both render; absent on the gh-pr-view path (gh omits avatar), then the UI + // falls back to the login URL and finally an initials placeholder. See #8784. + authorAvatarUrl?: string branchName?: string baseRefName?: string // Why: PR checks are keyed by head commit; carrying this lets task rows use From e01f3f474c39ce1a35c83d07c2adf36a380c48d3 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Thu, 16 Jul 2026 19:38:13 -0700 Subject: [PATCH 16/24] fix(sidebar): preserve Project Group members under Hide sleeping (#9101) * fix(sidebar): preserve grouped projects under sleep filter * test(sidebar): harden #8865 project-group sleep-filter coverage Add adversarial cases for explicit project filters, multi-member groups, ungrouped neighbors, and buildRows membership/count preservation. Co-authored-by: bbingz --------- Co-authored-by: bbingz --- .../src/components/sidebar/WorktreeList.tsx | 3 +- .../empty-project-placeholder-repos.test.ts | 105 ++++++++++++++++++ .../empty-project-placeholder-repos.ts | 8 +- .../sidebar/worktree-list-groups.test.ts | 67 +++++++++++ 4 files changed, 181 insertions(+), 2 deletions(-) diff --git a/src/renderer/src/components/sidebar/WorktreeList.tsx b/src/renderer/src/components/sidebar/WorktreeList.tsx index 81671c20616..948228984d8 100644 --- a/src/renderer/src/components/sidebar/WorktreeList.tsx +++ b/src/renderer/src/components/sidebar/WorktreeList.tsx @@ -5711,9 +5711,10 @@ const WorktreeList = React.memo(function WorktreeList({ groupBy, repos: visibleReposForRows, worktreesByRepo, + visibleWorktrees, filterRepoIds }) - }, [filterRepoIds, groupBy, visibleReposForRows, worktreesByRepo]) + }, [filterRepoIds, groupBy, visibleReposForRows, visibleWorktrees, worktreesByRepo]) const allRepoIds = useMemo(() => repos.map((r) => r.id), [repos]) // Why: buildRows only needs which creates exist and their repo. Subscribe on a diff --git a/src/renderer/src/components/sidebar/empty-project-placeholder-repos.test.ts b/src/renderer/src/components/sidebar/empty-project-placeholder-repos.test.ts index aaa3be238e9..8a33151f73a 100644 --- a/src/renderer/src/components/sidebar/empty-project-placeholder-repos.test.ts +++ b/src/renderer/src/components/sidebar/empty-project-placeholder-repos.test.ts @@ -38,6 +38,7 @@ describe('getEmptyProjectPlaceholderRepoIds', () => { groupBy: 'repo', repos: [repo], worktreesByRepo: { [repo.id]: [] }, + visibleWorktrees: [], filterRepoIds: [] }) ) @@ -51,6 +52,7 @@ describe('getEmptyProjectPlaceholderRepoIds', () => { groupBy: 'repo', repos: [repo], worktreesByRepo: {}, + visibleWorktrees: [], filterRepoIds: [] }) ) @@ -67,6 +69,7 @@ describe('getEmptyProjectPlaceholderRepoIds', () => { groupBy: 'repo', repos: [selectedRepo, hiddenRepo], worktreesByRepo: { [selectedRepo.id]: [], [hiddenRepo.id]: [] }, + visibleWorktrees: [], filterRepoIds: [selectedRepo.id] }) ) @@ -79,6 +82,7 @@ describe('getEmptyProjectPlaceholderRepoIds', () => { groupBy: 'none', repos: [repo], worktreesByRepo: { [repo.id]: [] }, + visibleWorktrees: [], filterRepoIds: [] }).size ).toBe(0) @@ -90,8 +94,109 @@ describe('getEmptyProjectPlaceholderRepoIds', () => { groupBy: 'repo', repos: [repo], worktreesByRepo: { [repo.id]: [worktree] }, + visibleWorktrees: [], filterRepoIds: [] }).size ).toBe(0) }) + + it('keeps grouped repos visible when workspace filters hide all of their rows', () => { + const groupedRepo: Repo = { ...repo, projectGroupId: 'group-1' } + const groupedWorktree: Worktree = { ...worktree, repoId: groupedRepo.id } + + expect( + Array.from( + getEmptyProjectPlaceholderRepoIds({ + groupBy: 'repo', + repos: [groupedRepo], + worktreesByRepo: { [groupedRepo.id]: [groupedWorktree] }, + visibleWorktrees: [], + filterRepoIds: [] + }) + ) + ).toEqual([groupedRepo.id]) + }) + + it('does not create a grouped repo placeholder when one of its workspaces is visible', () => { + const groupedRepo: Repo = { ...repo, projectGroupId: 'group-1' } + const groupedWorktree: Worktree = { ...worktree, repoId: groupedRepo.id } + + expect( + getEmptyProjectPlaceholderRepoIds({ + groupBy: 'repo', + repos: [groupedRepo], + worktreesByRepo: { [groupedRepo.id]: [groupedWorktree] }, + visibleWorktrees: [groupedWorktree], + filterRepoIds: [] + }).size + ).toBe(0) + }) + + it('still respects explicit project filters for sleep-filtered grouped members', () => { + const selected: Repo = { ...repo, id: 'repo-selected', projectGroupId: 'group-1' } + const filteredOut: Repo = { ...repo, id: 'repo-hidden', projectGroupId: 'group-1' } + const selectedWt: Worktree = { ...worktree, id: 'wt-selected', repoId: selected.id } + const hiddenWt: Worktree = { ...worktree, id: 'wt-hidden', repoId: filteredOut.id } + + expect( + Array.from( + getEmptyProjectPlaceholderRepoIds({ + groupBy: 'repo', + repos: [selected, filteredOut], + worktreesByRepo: { + [selected.id]: [selectedWt], + [filteredOut.id]: [hiddenWt] + }, + // Why: simulate Hide sleeping removing every card while the project + // filter still intentionally excludes `filteredOut`. + visibleWorktrees: [], + filterRepoIds: [selected.id] + }) + ) + ).toEqual([selected.id]) + }) + + it('placeholders only the fully-filtered members of a multi-project group', () => { + const sleeping: Repo = { ...repo, id: 'repo-sleeping', projectGroupId: 'group-1' } + const awake: Repo = { ...repo, id: 'repo-awake', projectGroupId: 'group-1' } + const sleepingWt: Worktree = { ...worktree, id: 'wt-sleeping', repoId: sleeping.id } + const awakeWt: Worktree = { ...worktree, id: 'wt-awake', repoId: awake.id } + + expect( + Array.from( + getEmptyProjectPlaceholderRepoIds({ + groupBy: 'repo', + repos: [sleeping, awake], + worktreesByRepo: { + [sleeping.id]: [sleepingWt], + [awake.id]: [awakeWt] + }, + visibleWorktrees: [awakeWt], + filterRepoIds: [] + }) + ) + ).toEqual([sleeping.id]) + }) + + it('does not placeholder ungrouped neighbors of a filtered grouped member', () => { + const grouped: Repo = { ...repo, id: 'repo-grouped', projectGroupId: 'group-1' } + const ungrouped: Repo = { ...repo, id: 'repo-ungrouped' } + const groupedWt: Worktree = { ...worktree, id: 'wt-grouped', repoId: grouped.id } + const ungroupedWt: Worktree = { ...worktree, id: 'wt-ungrouped', repoId: ungrouped.id } + + expect( + Array.from( + getEmptyProjectPlaceholderRepoIds({ + groupBy: 'repo', + repos: [grouped, ungrouped], + worktreesByRepo: { + [grouped.id]: [groupedWt], + [ungrouped.id]: [ungroupedWt] + }, + visibleWorktrees: [], + filterRepoIds: [] + }) + ) + ).toEqual([grouped.id]) + }) }) diff --git a/src/renderer/src/components/sidebar/empty-project-placeholder-repos.ts b/src/renderer/src/components/sidebar/empty-project-placeholder-repos.ts index 26c248a2a65..57aee59c1bd 100644 --- a/src/renderer/src/components/sidebar/empty-project-placeholder-repos.ts +++ b/src/renderer/src/components/sidebar/empty-project-placeholder-repos.ts @@ -5,6 +5,7 @@ export function getEmptyProjectPlaceholderRepoIds(args: { groupBy: WorktreeGroupBy repos: readonly Repo[] worktreesByRepo: Readonly> + visibleWorktrees: readonly Worktree[] filterRepoIds: readonly string[] }): Set { if (args.groupBy !== 'repo') { @@ -12,12 +13,17 @@ export function getEmptyProjectPlaceholderRepoIds(args: { } const filterSet = args.filterRepoIds.length > 0 ? new Set(args.filterRepoIds) : null + const visibleRepoIds = new Set(args.visibleWorktrees.map((worktree) => worktree.repoId)) const placeholderRepoIds = new Set() for (const repo of args.repos) { if (filterSet && !filterSet.has(repo.id)) { continue } - if ((args.worktreesByRepo[repo.id]?.length ?? 0) === 0) { + const hasNoWorktrees = (args.worktreesByRepo[repo.id]?.length ?? 0) === 0 + // Why: workspace filters hide cards, but must not rewrite the visible + // membership of a persisted Project Group. #8865 + const isFilteredProjectGroupMember = repo.projectGroupId != null && !visibleRepoIds.has(repo.id) + if (hasNoWorktrees || isFilteredProjectGroupMember) { placeholderRepoIds.add(repo.id) } } diff --git a/src/renderer/src/components/sidebar/worktree-list-groups.test.ts b/src/renderer/src/components/sidebar/worktree-list-groups.test.ts index b967c4cd356..4b4012f8c7c 100644 --- a/src/renderer/src/components/sidebar/worktree-list-groups.test.ts +++ b/src/renderer/src/components/sidebar/worktree-list-groups.test.ts @@ -1933,6 +1933,73 @@ describe('project groups', () => { expect(rows[0]).toMatchObject({ label: 'Platform' }) }) + it('keeps sleep-filtered Project Group members as empty project headers', () => { + // Why: #8865 — Hide sleeping removes workspace cards; membership placeholders + // must still project the grouped project header so the group count stays honest. + const group: ProjectGroup = { + id: 'group-1', + name: 'Platform', + parentPath: '/platform', + parentGroupId: null, + createdFrom: 'folder-scan', + tabOrder: 0, + isCollapsed: false, + color: null, + createdAt: 1, + updatedAt: 1 + } + const sleepingRepo: Repo = { + ...repo, + id: 'repo-sleeping', + displayName: 'sleeping-project', + projectGroupId: group.id + } + const awakeRepo: Repo = { + ...repo, + id: 'repo-awake', + displayName: 'awake-project', + projectGroupId: group.id + } + const awakeWorktree: Worktree = { + ...worktree, + id: 'wt-awake', + repoId: awakeRepo.id, + path: '/tmp/awake' + } + + const rows = buildRows( + 'repo', + [awakeWorktree], + new Map([ + [sleepingRepo.id, sleepingRepo], + [awakeRepo.id, awakeRepo] + ]), + null, + new Set(), + undefined, + undefined, + undefined, + {}, + new Map([[awakeWorktree.id, awakeWorktree]]), + false, + undefined, + [group], + new Set([sleepingRepo.id]) + ) + + expect(rows[0]).toMatchObject({ + type: 'header', + key: 'project-group:group-1', + count: 2 + }) + // Why: empty/placeholder projects sort after projects with visible activity. + expect(rows.filter((row) => row.type === 'header').map((row) => row.key)).toEqual([ + 'project-group:group-1', + `repo:${awakeRepo.id}`, + `repo:${sleepingRepo.id}` + ]) + }) + it('renders ungrouped repos as top-level repo rows when Project Groups exist', () => { const group: ProjectGroup = { id: 'group-1', From 5ee90c8d59f5839b5a05fe5e42db32c40b936273 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Thu, 16 Jul 2026 19:53:18 -0700 Subject: [PATCH 17/24] fix(agents): recognize OpenCode native OC | tab titles (#9102) * fix(agents): recognize OpenCode native OC | tab titles OpenCode's native OSC titles use `OC | ` without an `opencode` token, so title classifiers left tabs as Claude/unknown. Map the native marker (optional mux prefix) to OpenCode identity in both title classifiers, exclude it from isClaudeAgent, and cover lookalikes plus stale Claude launch reclaim. Builds on and supersedes #8590 (credit @gatsby74). Fixes #8478. * fix(agents): drop renderer import from #8478 shared repro tsconfig.node includes src/shared tests; importing agent-status pulled renderer modules outside the node project and failed typecheck. Assert opencode identity via shared title classifiers only (OpenCode TUI sets "OpenCode" and `OC | ${title}`). * fix(runtime): fill browser cert failure map in mobile snapshot fixtures Main's #9104 reads browserCertificateFailuresByPageId in buildMobileBrowserTab but left partial AppState test helpers without the field, breaking PR Checks merge commits. Default the map in fixtures and use optional chaining so partial state cannot throw. --- src/renderer/src/lib/use-tab-agent.test.ts | 14 ++++++ ...sync-runtime-graph-terminal-layout.test.ts | 1 + .../src/runtime/sync-runtime-graph.test.ts | 2 + .../src/runtime/sync-runtime-graph.ts | 2 +- src/shared/agent-detection.ts | 1 + src/shared/agent-title-identity.ts | 8 ++- src/shared/opencode-terminal-title.test.ts | 11 ++++- src/shared/opencode-terminal-title.ts | 14 +++++- ...ro-8478-opencode-native-title-icon.test.ts | 49 +++++++++++++++++++ src/shared/terminal-title-agent-type.test.ts | 35 +++++++++++++ src/shared/terminal-title-agent-type.ts | 8 ++- 11 files changed, 138 insertions(+), 7 deletions(-) create mode 100644 src/shared/repro-8478-opencode-native-title-icon.test.ts diff --git a/src/renderer/src/lib/use-tab-agent.test.ts b/src/renderer/src/lib/use-tab-agent.test.ts index 9152159f14b..d2283db7dea 100644 --- a/src/renderer/src/lib/use-tab-agent.test.ts +++ b/src/renderer/src/lib/use-tab-agent.test.ts @@ -258,6 +258,20 @@ describe('resolveTabAgentFromSignals', () => { ).toBe('claude') }) + // Why: #8478 — OpenCode native `OC | …` titles must reclaim a stale Claude + // launch identity so the tab icon is OpenCode, not Claude. + it('uses OpenCode native session titles to replace stale Claude launch identity', () => { + expect( + resolveTabAgentFromSignals({ + hasObservedAgentSignal: true, + isRemote: false, + title: 'OC | Understand about the plugin', + hookAgent: null, + launchAgent: 'claude' + }) + ).toBe('opencode') + }) + it('does not let an explicit title override launch identity before any activity is observed', () => { expect( resolveTabAgentFromSignals({ diff --git a/src/renderer/src/runtime/sync-runtime-graph-terminal-layout.test.ts b/src/renderer/src/runtime/sync-runtime-graph-terminal-layout.test.ts index 6095f4e1840..385a7764fd1 100644 --- a/src/renderer/src/runtime/sync-runtime-graph-terminal-layout.test.ts +++ b/src/renderer/src/runtime/sync-runtime-graph-terminal-layout.test.ts @@ -16,6 +16,7 @@ function makeState(overrides: Partial = {}): AppState { activeBrowserTabIdByWorktree: {}, browserTabsByWorktree: {}, browserPagesByWorkspace: {}, + browserCertificateFailuresByPageId: {}, openFiles: [], editorDrafts: {}, activeTabId: null, diff --git a/src/renderer/src/runtime/sync-runtime-graph.test.ts b/src/renderer/src/runtime/sync-runtime-graph.test.ts index 2de176df7b9..d1082d89cab 100644 --- a/src/renderer/src/runtime/sync-runtime-graph.test.ts +++ b/src/renderer/src/runtime/sync-runtime-graph.test.ts @@ -26,6 +26,7 @@ function makeState(overrides: Partial = {}): AppState { activeBrowserTabIdByWorktree: {}, browserTabsByWorktree: {}, browserPagesByWorkspace: {}, + browserCertificateFailuresByPageId: {}, openFiles: [], editorDrafts: {}, activeTabId: null, @@ -60,6 +61,7 @@ function makeSharedOverrides(): Partial { activeBrowserTabIdByWorktree: {}, browserTabsByWorktree: {}, browserPagesByWorkspace: {}, + browserCertificateFailuresByPageId: {}, openFiles: [], editorDrafts: {}, agentStatusByPaneKey: {}, diff --git a/src/renderer/src/runtime/sync-runtime-graph.ts b/src/renderer/src/runtime/sync-runtime-graph.ts index e735e130004..fb32a847941 100644 --- a/src/renderer/src/runtime/sync-runtime-graph.ts +++ b/src/renderer/src/runtime/sync-runtime-graph.ts @@ -1496,7 +1496,7 @@ function buildMobileBrowserTab( // back through ?? would resurrect a stale workspace-level error. loadError: activePage ? activePage.loadError : workspace.loadError, certificateFailure: activePage - ? (state.browserCertificateFailuresByPageId[activePage.id] ?? null) + ? (state.browserCertificateFailuresByPageId?.[activePage.id] ?? null) : null, color: unifiedTab?.color ?? null, isPinned: unifiedTab?.isPinned === true, diff --git a/src/shared/agent-detection.ts b/src/shared/agent-detection.ts index 891f37093db..4fbfff86f24 100644 --- a/src/shared/agent-detection.ts +++ b/src/shared/agent-detection.ts @@ -18,6 +18,7 @@ export { STRONG_IDLE_KEYWORDS_RE, STRONG_WORKING_KEYWORDS_RE } from './agent-title-core' +export { isOpenCodeNativeTitle, isMeaningfulOpenCodeTerminalTitle } from './opencode-terminal-title' export { getAgentLabel, isClaudeAgent } from './agent-title-identity' export { clearWorkingIndicators, diff --git a/src/shared/agent-title-identity.ts b/src/shared/agent-title-identity.ts index 9f7746b01bc..af86de1667d 100644 --- a/src/shared/agent-title-identity.ts +++ b/src/shared/agent-title-identity.ts @@ -10,6 +10,7 @@ import { isPiAgentTitle, titleHasAgentName } from './agent-title-core' +import { isOpenCodeNativeTitle } from './opencode-terminal-title' import { getPiCompatibleSyntheticAgentLabel } from './pi-compatible-synthetic-title' /** @@ -17,7 +18,7 @@ import { getPiCompatibleSyntheticAgentLabel } from './pi-compatible-synthetic-ti * Used to scope prompt-cache-timer behavior to Claude sessions only. */ export function isClaudeAgent(title: string): boolean { - if (!title || isClaudeManagementTitle(title)) { + if (!title || isClaudeManagementTitle(title) || isOpenCodeNativeTitle(title)) { return false } const lower = title.toLowerCase() @@ -46,6 +47,11 @@ export function getAgentLabel(title: string): string | null { if (isClaudeManagementTitle(title)) { return null } + // Why: the native marker owns the whole title; its session text may name or + // include status glyphs from other agents without changing OpenCode identity. + if (isOpenCodeNativeTitle(title)) { + return 'OpenCode' + } // Why: Claude task titles can mention another CLI; the prefix is the identity // signal, not arbitrary task text. if ( diff --git a/src/shared/opencode-terminal-title.test.ts b/src/shared/opencode-terminal-title.test.ts index fc1b7806e35..f1cb825e8cd 100644 --- a/src/shared/opencode-terminal-title.test.ts +++ b/src/shared/opencode-terminal-title.test.ts @@ -1,16 +1,23 @@ import { describe, expect, it } from 'vitest' -import { isMeaningfulOpenCodeTerminalTitle } from './opencode-terminal-title' +import { isMeaningfulOpenCodeTerminalTitle, isOpenCodeNativeTitle } from './opencode-terminal-title' describe('OpenCode terminal titles', () => { it('recognizes native session titles', () => { expect(isMeaningfulOpenCodeTerminalTitle('OC | Native Stable Session')).toBe(true) expect(isMeaningfulOpenCodeTerminalTitle(' OC|Session ')).toBe(true) + expect(isOpenCodeNativeTitle('OC | Understand about the plugin')).toBe(true) + expect(isOpenCodeNativeTitle('tmux | OC | ses_123')).toBe(true) }) - it('rejects generic or incomplete OpenCode titles', () => { + it('rejects generic, incomplete, embedded, and lookalike titles', () => { expect(isMeaningfulOpenCodeTerminalTitle('OpenCode')).toBe(false) expect(isMeaningfulOpenCodeTerminalTitle('OpenCode ready')).toBe(false) expect(isMeaningfulOpenCodeTerminalTitle('OC |')).toBe(false) expect(isMeaningfulOpenCodeTerminalTitle(undefined)).toBe(false) + // Why: lowercase is not OpenCode's native marker; avoid "oc |" cwd/task noise. + expect(isOpenCodeNativeTitle('oc | Understand about the plugin')).toBe(false) + // Why: mid-title OC must not steal another agent's braille/task frame. + expect(isOpenCodeNativeTitle('⠋ Fix foo | OC | bar')).toBe(false) + expect(isOpenCodeNativeTitle('my session | OC | task')).toBe(false) }) }) diff --git a/src/shared/opencode-terminal-title.ts b/src/shared/opencode-terminal-title.ts index 64312b94be2..01d3d3819ae 100644 --- a/src/shared/opencode-terminal-title.ts +++ b/src/shared/opencode-terminal-title.ts @@ -1,4 +1,14 @@ +// Why: OpenCode abbreviates native OSC session titles as `OC | ` (no +// agent-name token). Optional single-token multiplexer prefix covers SSH/tmux +// frames like `tmux | OC | …`. Case-sensitive `OC` avoids ordinary lowercase +// "oc" lookalikes; require non-whitespace after the marker so bare `OC |` is not +// identity. Used for both display-title preservation and tab-agent identity. +const OPENCODE_NATIVE_TITLE_RE = /^(?:[^|\s]+ \| )?OC\s*\|\s*\S/u + +export function isOpenCodeNativeTitle(title: string | null | undefined): boolean { + return OPENCODE_NATIVE_TITLE_RE.test(title?.trim() ?? '') +} + export function isMeaningfulOpenCodeTerminalTitle(title: string | null | undefined): boolean { - // Why: bare OpenCode labels are status/identity; `OC | …` carries native session identity. - return /^OC\s*\|\s*\S/u.test(title?.trim() ?? '') + return isOpenCodeNativeTitle(title) } diff --git a/src/shared/repro-8478-opencode-native-title-icon.test.ts b/src/shared/repro-8478-opencode-native-title-icon.test.ts new file mode 100644 index 00000000000..554ac0c5ddb --- /dev/null +++ b/src/shared/repro-8478-opencode-native-title-icon.test.ts @@ -0,0 +1,49 @@ +/** + * Issue #8478 — OpenCode logo / icon not coming up well (Claude glyph on + * OpenCode tabs). + * + * Root cause (pre-fix): OpenCode's native OSC tab title format is `OC | `. + * Title classifiers only recognized OpenCode when the token "opencode" appeared. + * Native `OC | …` titles fell through, so AgentIcon rendered Claude/"?". + * + * OpenCode source (packages/tui/src/app.tsx) sets titles as: + * - "OpenCode" on home / default session titles + * - `OC | ${title}` for named sessions (title truncated at 40 chars) + * - `OC | ${pluginId}` on plugin routes + * + * Related: #8940 (OpenCode activity frames mislabeled Claude Code — separate + * braille-without-token path; not fully solved here). + * + * Re-run: + * pnpm exec vitest run --config config/vitest.config.ts \ + * src/shared/repro-8478-opencode-native-title-icon.test.ts + */ +import { describe, expect, it } from 'vitest' +import { getAgentLabel, isClaudeAgent } from './agent-detection' +import { + resolveExplicitTerminalTitleAgentType, + resolveTerminalTitleAgentType +} from './terminal-title-agent-type' + +describe('#8478 OpenCode native OC | titles map to OpenCode icon', () => { + it('recognizes OpenCode native "OC | …" title format as opencode identity', () => { + const native = 'OC | Understand about the plugin' + expect(getAgentLabel(native)).toBe('OpenCode') + expect(resolveTerminalTitleAgentType(native)).toBe('opencode') + expect(resolveExplicitTerminalTitleAgentType(native)).toBe('opencode') + expect(isClaudeAgent(native)).toBe(false) + }) + + // Why: bare "OpenCode" is what the TUI sets on home/default sessions; keep it + // classified so the icon path stays OpenCode without the OC | abbreviation. + it('keeps bare OpenCode home titles and Claude-style frames classified correctly', () => { + expect(getAgentLabel('OpenCode')).toBe('OpenCode') + expect(resolveTerminalTitleAgentType('OpenCode')).toBe('opencode') + expect(getAgentLabel('OpenCode ready')).toBe('OpenCode') + expect(resolveTerminalTitleAgentType('OpenCode ready')).toBe('opencode') + // Same family as #8940: braille/task frames without "opencode" still become Claude + // when they lack the native OC marker — documented, out of scope for this fix. + expect(isClaudeAgent('⠋ implementing the feature')).toBe(true) + expect(getAgentLabel('⠋ implementing the feature')).toBe('Claude Code') + }) +}) diff --git a/src/shared/terminal-title-agent-type.test.ts b/src/shared/terminal-title-agent-type.test.ts index d28dd43f503..d0fda7dcc45 100644 --- a/src/shared/terminal-title-agent-type.test.ts +++ b/src/shared/terminal-title-agent-type.test.ts @@ -1,4 +1,6 @@ import { describe, expect, it } from 'vitest' +import { getAgentLabel as getSharedAgentLabel } from './agent-title-identity' +import { isOpenCodeNativeTitle } from './opencode-terminal-title' import { isClaudeAgent, isGrokRotatingWorkingTitle, @@ -51,6 +53,39 @@ describe('resolveExplicitTerminalTitleAgentType', () => { expect(resolveExplicitTerminalTitleAgentType('* Review Codex behavior')).toBeNull() }) + it('resolves OpenCode native abbreviated session titles before task-text identities', () => { + expect(resolveExplicitTerminalTitleAgentType('OC | Understand about the plugin')).toBe( + 'opencode' + ) + expect(resolveExplicitTerminalTitleAgentType('OC | Compare Codex and Claude')).toBe('opencode') + // Why: Gemini glyphs inside OpenCode session text must not rebrand the tab. + expect(resolveExplicitTerminalTitleAgentType('OC | ✦ Gemini CLI')).toBe('opencode') + expect(getSharedAgentLabel('OC | Compare Codex and Claude')).toBe('OpenCode') + expect(getSharedAgentLabel('OC | ✦ Gemini CLI')).toBe('OpenCode') + expect(resolveExplicitTerminalTitleAgentType('tmux | OC | ses_123')).toBe('opencode') + expect(resolveExplicitTerminalTitleAgentType('OC|compact-session')).toBe('opencode') + expect(resolveExplicitTerminalTitleAgentType('oc | Understand about the plugin')).toBeNull() + }) + + it('does not find an OpenCode marker inside another agent task title', () => { + expect(isOpenCodeNativeTitle('⠋ Fix foo | OC | bar')).toBe(false) + expect(resolveExplicitTerminalTitleAgentType('⠋ Fix foo | OC | bar')).toBeNull() + }) + + // Why: adversarial coverage — native OC must not steal Claude/Codex/Cursor/ + // Gemini/Pi identity, and those agents must keep resolving when titled normally. + it('keeps other agents classified correctly alongside OpenCode native titles', () => { + expect(resolveExplicitTerminalTitleAgentType('✳ Claude Code')).toBe('claude') + expect(resolveExplicitTerminalTitleAgentType('⠋ Codex')).toBe('codex') + expect(resolveExplicitTerminalTitleAgentType('✦ Gemini CLI')).toBe('gemini') + expect(resolveExplicitTerminalTitleAgentType('Cursor Agent')).toBe('cursor') + expect(resolveExplicitTerminalTitleAgentType('Pi ready')).toBe('pi') + expect(resolveExplicitTerminalTitleAgentType('OpenCode ready')).toBe('opencode') + expect(resolveTerminalTitleAgentType('OC | ⠋ implementing the feature')).toBe('opencode') + expect(isClaudeAgent('OC | ⠋ implementing the feature')).toBe(false) + expect(isClaudeAgent('OC | Understand about the plugin')).toBe(false) + }) + it('still resolves Claude when the title explicitly names Claude', () => { expect(resolveExplicitTerminalTitleAgentType('. Claude Code compare Opencode')).toBe('claude') }) diff --git a/src/shared/terminal-title-agent-type.ts b/src/shared/terminal-title-agent-type.ts index ae7c97c09e7..fa92bb198c0 100644 --- a/src/shared/terminal-title-agent-type.ts +++ b/src/shared/terminal-title-agent-type.ts @@ -5,6 +5,7 @@ import { titleHasAgentName } from './agent-name-token-match' import { isCursorAgentTitle } from './agent-title-core' +import { isOpenCodeNativeTitle } from './opencode-terminal-title' import { getPiCompatibleSyntheticAgentLabel, isLegacyPiCompatibleTitle @@ -80,7 +81,7 @@ export function isPiAgentTitle(title: string): boolean { * agents have different (or no) caching semantics. */ export function isClaudeAgent(title: string): boolean { - if (!title || isClaudeManagementTitle(title)) { + if (!title || isClaudeManagementTitle(title) || isOpenCodeNativeTitle(title)) { return false } const lower = title.toLowerCase() @@ -124,6 +125,11 @@ export function getAgentLabel(title: string): string | null { if (isClaudeManagementTitle(title)) { return null } + // Why: the native marker owns the whole title; its session text may name or + // include status glyphs from other agents without changing OpenCode identity. + if (isOpenCodeNativeTitle(title)) { + return 'OpenCode' + } // Why: Claude Code title text is often the task title. If that task mentions // another CLI, the Claude-specific prefix is the identity signal, not the words. if ( From cc1ad064d74e21a6a14713e31004e6ff0e53a982 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 16 Jul 2026 20:02:01 -0700 Subject: [PATCH 18/24] fix(skills): decouple bundled skill artifacts from the release train (#9119) The current manifest stamped package.json's version into itself (9 lines), so every RC/stable version bump made the committed artifact stale on every open branch: lint failed until authors committed content-free regeneration diffs, which also dragged the resources/skills-filtered update-roundtrip matrix onto unrelated PRs. Cutting a release tag whose skills tree changed had the same effect through release-mapping.json. - current-manifest.json is now schema 2 and content-only; the generator no longer reads package.json. Registry and mapping stay schema 1 so the append-only released-history guard keeps its schema gate. - The running build's version enters at the IPC boundary (skills:freshnessInventory passes app.getVersion()) and threads through the inventory to placement observation; current-revision placements are labeled with it while historical revisions keep resolving through the release mapping. The artifact loader and its cache stay content-only. - verify tolerates a committed release mapping that is a byte-exact prefix of the derived one when every missing trailing row's revisions equal the current manifest (a just-cut tag over unchanged-since bytes); such rows are provably redundant until the next real regeneration adds them. Artifacts now change only when skills/ content changes. --- .github/workflows/release-cut.yml | 5 +- .../generate-skill-bundle-manifest.mjs | 68 +++++++++++++++---- .../generate-skill-bundle-manifest.test.mjs | 51 ++++++++++++++ ...package-electron-runtime-contract.test.mjs | 13 ++-- resources/skills/current-manifest.json | 11 +-- src/main/ipc/skills.test.ts | 8 ++- src/main/ipc/skills.ts | 7 +- .../skills/skill-bundle-artifacts.test.ts | 42 ++++++++++++ src/main/skills/skill-bundle-artifacts.ts | 11 +-- .../skills/skill-freshness-inventory.test.ts | 19 +++++- src/main/skills/skill-freshness-inventory.ts | 24 ++++--- .../skill-freshness-placement-observation.ts | 18 ++++- src/shared/skill-freshness.ts | 6 +- 13 files changed, 218 insertions(+), 65 deletions(-) diff --git a/.github/workflows/release-cut.yml b/.github/workflows/release-cut.yml index 1409ed1c371..605645186fb 100644 --- a/.github/workflows/release-cut.yml +++ b/.github/workflows/release-cut.yml @@ -472,10 +472,7 @@ jobs: # message and tag name explicitly (avoids npm's `v1.2.3` prefix # assumptions and any lifecycle scripts that would run on bump). npm version "$VERSION" --no-git-tag-version --allow-same-version - # Why: package version is embedded in generated skill provenance; full tag - # history preserves released snapshots while the current version is updated. - node config/scripts/generate-skill-bundle-manifest.mjs --write - git add package.json resources/skills + git add package.json commit_message="release: v$VERSION" if [[ "$EVENT_NAME" == "schedule" ]]; then commit_message="$commit_message [rc-slot:$SLOT]" diff --git a/config/scripts/generate-skill-bundle-manifest.mjs b/config/scripts/generate-skill-bundle-manifest.mjs index c3542d6f0e2..d4ba49c9b52 100644 --- a/config/scripts/generate-skill-bundle-manifest.mjs +++ b/config/scripts/generate-skill-bundle-manifest.mjs @@ -6,7 +6,11 @@ import path from 'node:path' import process from 'node:process' import { isDeepStrictEqual } from 'node:util' -const SCHEMA_VERSION = 1 +// Why: the three artifacts version independently — bumping one shape must not +// rewrite the others or bypass the registry's schema-gated append-only guard. +const CURRENT_MANIFEST_SCHEMA_VERSION = 2 +const SNAPSHOT_REGISTRY_SCHEMA_VERSION = 1 +const RELEASE_MAPPING_SCHEMA_VERSION = 1 const SCRIPT_DIR = import.meta.dirname const REPO_ROOT = path.resolve(SCRIPT_DIR, '..', '..') const SKILLS_ROOT = path.join(REPO_ROOT, 'skills') @@ -313,8 +317,8 @@ function skillsTreeShasAtRefs(refs) { } function buildReleasedHistory() { - const registry = { schemaVersion: SCHEMA_VERSION, skills: {} } - const mapping = { schemaVersion: SCHEMA_VERSION, releases: [] } + const registry = { schemaVersion: SNAPSHOT_REGISTRY_SCHEMA_VERSION, skills: {} } + const mapping = { schemaVersion: RELEASE_MAPPING_SCHEMA_VERSION, releases: [] } const tags = releaseTags() const treeShas = skillsTreeShasAtRefs(tags) const distinctTreeShas = [...new Set(treeShas.filter(Boolean))] @@ -366,7 +370,10 @@ function buildReleasedHistory() { return { registry, mapping } } -async function buildArtifacts(appVersion) { +// Why: the artifacts must be pure functions of skills/ bytes and release-tag +// history. Stamping the app version made every release cut invalidate the +// committed output on all open branches and drag skill CI onto unrelated PRs. +async function buildArtifacts() { const { registry, mapping } = buildReleasedHistory() const releasedSnapshotCounts = Object.fromEntries( Object.entries(registry.skills).map(([name, snapshots]) => [name, snapshots.length]) @@ -399,14 +406,12 @@ async function buildArtifacts(appVersion) { currentSkills.push({ name, sourcePath: `skills/${name}`, - appVersion, ...snapshot }) } return { currentManifest: { - schemaVersion: SCHEMA_VERSION, - appVersion, + schemaVersion: CURRENT_MANIFEST_SCHEMA_VERSION, skills: currentSkills }, snapshotRegistry: registry, @@ -419,7 +424,7 @@ async function buildArtifacts(appVersion) { // so a generation-logic change must not rewrite them silently. Only the one // unreleased working-tree append per skill may change between runs. function assertReleasedHistoryPreserved(committedRegistry, artifacts) { - if (!committedRegistry || committedRegistry.schemaVersion !== SCHEMA_VERSION) { + if (!committedRegistry || committedRegistry.schemaVersion !== SNAPSHOT_REGISTRY_SCHEMA_VERSION) { return } for (const [name, committedSnapshots] of Object.entries(committedRegistry.skills ?? {})) { @@ -466,17 +471,50 @@ async function writeArtifacts(artifacts) { ]) } +// Why: cutting a release tag adds a trailing mapping row on every checkout at +// once, before any branch can regenerate. A trailing row whose revisions all +// equal the current manifest is provably redundant until the next real +// regeneration (installs of those bytes classify as current and are labeled by +// the running build), so verify must not fail branches over it. +function isToleratedReleaseMappingPrefix(committedText, artifacts) { + let committed + try { + committed = JSON.parse(committedText) + } catch { + return false + } + const derived = artifacts.releaseMapping + const committedCount = Array.isArray(committed?.releases) ? committed.releases.length : -1 + if (committedCount < 0 || committedCount >= derived.releases.length) { + return false + } + const prefix = { + schemaVersion: derived.schemaVersion, + releases: derived.releases.slice(0, committedCount) + } + if (committedText !== serialized(prefix)) { + return false + } + const currentRevisions = Object.fromEntries( + artifacts.currentManifest.skills.map((skill) => [skill.name, skill.releaseRevision]) + ) + return derived.releases + .slice(committedCount) + .every((release) => isDeepStrictEqual(release.skills, currentRevisions)) +} + async function verifyArtifacts(artifacts) { const expected = [ - [CURRENT_MANIFEST_PATH, artifacts.currentManifest], - [SNAPSHOT_REGISTRY_PATH, artifacts.snapshotRegistry], - [RELEASE_MAPPING_PATH, artifacts.releaseMapping] + [CURRENT_MANIFEST_PATH, artifacts.currentManifest, null], + [SNAPSHOT_REGISTRY_PATH, artifacts.snapshotRegistry, null], + [RELEASE_MAPPING_PATH, artifacts.releaseMapping, isToleratedReleaseMappingPrefix] ] const stale = [] - for (const [filePath, value] of expected) { + for (const [filePath, value, tolerated] of expected) { try { await access(filePath, constants.R_OK) - if ((await readFile(filePath, 'utf8')) !== serialized(value)) { + const committedText = await readFile(filePath, 'utf8') + if (committedText !== serialized(value) && !tolerated?.(committedText, artifacts)) { stale.push(filePath) } } catch { @@ -493,8 +531,7 @@ async function verifyArtifacts(artifacts) { } async function main() { - const packageJson = JSON.parse(await readFile(path.join(REPO_ROOT, 'package.json'), 'utf8')) - const artifacts = await buildArtifacts(packageJson.version) + const artifacts = await buildArtifacts() assertReleasedHistoryPreserved(await readCommittedRegistry(), artifacts) await (process.argv.includes('--write') ? writeArtifacts : verifyArtifacts)(artifacts) } @@ -514,6 +551,7 @@ export { collectPackageFiles, describeFile, gitTreeSha, + isToleratedReleaseMappingPrefix, normalizeText, packageDigest, sortManifestFiles, diff --git a/config/scripts/generate-skill-bundle-manifest.test.mjs b/config/scripts/generate-skill-bundle-manifest.test.mjs index cf4a8a100e5..a6539f800a0 100644 --- a/config/scripts/generate-skill-bundle-manifest.test.mjs +++ b/config/scripts/generate-skill-bundle-manifest.test.mjs @@ -9,6 +9,7 @@ import { collectPackageFiles, describeFile, gitTreeSha, + isToleratedReleaseMappingPrefix, normalizeText, packageDigest, sortManifestFiles @@ -136,6 +137,56 @@ describe('skill bundle manifest generator', () => { expect(() => assertReleasedHistoryPreserved(null, artifacts)).not.toThrow() }) + it('tolerates only redundant trailing release-mapping rows', () => { + const serialized = (value) => `${JSON.stringify(value, null, 2)}\n` + const rows = [ + { appVersion: '1.0.0', skills: { 'orca-cli': 1 } }, + { appVersion: '1.1.0', skills: { 'orca-cli': 2 } } + ] + const artifacts = { + currentManifest: { skills: [{ name: 'orca-cli', releaseRevision: 2 }] }, + releaseMapping: { schemaVersion: 1, releases: rows } + } + const committedPrefix = serialized({ schemaVersion: 1, releases: [rows[0]] }) + + // A just-cut tag whose bytes equal the working tree may lag in the mapping. + expect(isToleratedReleaseMappingPrefix(committedPrefix, artifacts)).toBe(true) + // The committed file matching the derived mapping is byte-equality's job, not tolerance. + expect(isToleratedReleaseMappingPrefix(serialized(artifacts.releaseMapping), artifacts)).toBe( + false + ) + // A trailing row for bytes the committed artifacts do not describe is a real gap. + expect( + isToleratedReleaseMappingPrefix(committedPrefix, { + ...artifacts, + currentManifest: { skills: [{ name: 'orca-cli', releaseRevision: 3 }] } + }) + ).toBe(false) + expect( + isToleratedReleaseMappingPrefix(committedPrefix, { + ...artifacts, + currentManifest: { + skills: [ + { name: 'orca-cli', releaseRevision: 2 }, + { name: 'orca-linear', releaseRevision: 1 } + ] + } + }) + ).toBe(false) + // Rewritten earlier rows never pass, with or without trailing rows. + expect( + isToleratedReleaseMappingPrefix( + serialized({ + schemaVersion: 1, + releases: [{ appVersion: '0.9.0', skills: { 'orca-cli': 1 } }] + }), + artifacts + ) + ).toBe(false) + expect(isToleratedReleaseMappingPrefix('not json', artifacts)).toBe(false) + expect(isToleratedReleaseMappingPrefix(serialized({ schemaVersion: 1 }), artifacts)).toBe(false) + }) + it.runIf(process.platform !== 'win32')( 'rejects executable files in shipped skill packages', async () => { diff --git a/config/scripts/package-electron-runtime-contract.test.mjs b/config/scripts/package-electron-runtime-contract.test.mjs index e6f6018e6b0..c18272d56a4 100644 --- a/config/scripts/package-electron-runtime-contract.test.mjs +++ b/config/scripts/package-electron-runtime-contract.test.mjs @@ -374,7 +374,7 @@ describe('Electron runtime package contract', () => { expect(afterInstallScript).not.toContain('chmod 0755 "$sandbox"') }) - it('keeps release-cut version commits self-healing and taggable on retries', () => { + it('keeps release-cut version commits skill-independent and taggable on retries', () => { const releaseWorkflow = readFileSync( join(projectDir, '.github/workflows/release-cut.yml'), 'utf8' @@ -388,14 +388,13 @@ describe('Electron runtime package contract', () => { const bumpIndex = bumpStep.run.indexOf( 'npm version "$VERSION" --no-git-tag-version --allow-same-version' ) - const generateIndex = bumpStep.run.indexOf( - 'node config/scripts/generate-skill-bundle-manifest.mjs --write' - ) - const stageIndex = bumpStep.run.indexOf('git add package.json resources/skills') + const stageIndex = bumpStep.run.indexOf('git add package.json') expect(checkoutStep.with['fetch-depth']).toBe(0) expect(bumpIndex).toBeGreaterThanOrEqual(0) - expect(generateIndex).toBeGreaterThan(bumpIndex) - expect(stageIndex).toBeGreaterThan(generateIndex) + expect(stageIndex).toBeGreaterThan(bumpIndex) + // Why: version-only cuts must not mutate content-addressed skill artifacts. + expect(bumpStep.run).not.toContain('generate-skill-bundle-manifest') + expect(bumpStep.run).not.toContain('resources/skills') expect(bumpStep.run).toContain('git diff --cached --quiet') expect(bumpStep.run).toContain('git commit --allow-empty -m "$commit_message"') }) diff --git a/resources/skills/current-manifest.json b/resources/skills/current-manifest.json index cea063aa334..b0053b7b118 100644 --- a/resources/skills/current-manifest.json +++ b/resources/skills/current-manifest.json @@ -1,11 +1,9 @@ { - "schemaVersion": 1, - "appVersion": "1.4.144-rc.4", + "schemaVersion": 2, "skills": [ { "name": "computer-use", "sourcePath": "skills/computer-use", - "appVersion": "1.4.144-rc.4", "releaseRevision": 5, "packageDigest": "cd2809474d57fd7277adb277448e6fa446810d3cbad71ac0b473b9e8ff1bad68", "gitTreeSha": "306c0f8cb63bcac265a5b7975dc2f855be4f1344", @@ -24,7 +22,6 @@ { "name": "linear-tickets", "sourcePath": "skills/linear-tickets", - "appVersion": "1.4.144-rc.4", "releaseRevision": 4, "packageDigest": "f198d7b22e5ee1673dac403f9cca0553b124e0a90e4fdd05d2c23b7344e32d2b", "gitTreeSha": "de9fc106bbb4e313a90ff9a9513a720909bbd176", @@ -43,7 +40,6 @@ { "name": "orca-cli", "sourcePath": "skills/orca-cli", - "appVersion": "1.4.144-rc.4", "releaseRevision": 32, "packageDigest": "51740ff13f379ac5743d3fd28a14b17168dcef40f7048c20182dce166098c45f", "gitTreeSha": "ded93000a5f654e2b4f324501282459bd56afe19", @@ -62,7 +58,6 @@ { "name": "orca-emulator", "sourcePath": "skills/orca-emulator", - "appVersion": "1.4.144-rc.4", "releaseRevision": 4, "packageDigest": "453b1d9aa20b51b8a4d32c7b6def6a93f7ef9c730de32abbcbc1788ad1b1820b", "gitTreeSha": "66be6abe99f1807da85934aee0e22daefc8f7656", @@ -81,7 +76,6 @@ { "name": "orca-emulator-android", "sourcePath": "skills/orca-emulator-android", - "appVersion": "1.4.144-rc.4", "releaseRevision": 2, "packageDigest": "12272cf82e0731f11e424822b961882457034e730358cc65ea28e4eb9c8ff7f5", "gitTreeSha": "f7b0fc8cbf5cd78ca5156f6bbe3a20f1462d8f83", @@ -100,7 +94,6 @@ { "name": "orca-linear", "sourcePath": "skills/orca-linear", - "appVersion": "1.4.144-rc.4", "releaseRevision": 2, "packageDigest": "d44d09e6ecb6a64da177083aad26a95f031cd1cf26ba059fdc888c2628aef64f", "gitTreeSha": "c34f42030f43e5a85737996fa375bbd79cb5bea8", @@ -119,7 +112,6 @@ { "name": "orca-per-workspace-env", "sourcePath": "skills/orca-per-workspace-env", - "appVersion": "1.4.144-rc.4", "releaseRevision": 2, "packageDigest": "fa3b65a1a107fca3f0375c696852477b62f58c154b9eb5c0663c41edc4bcd30d", "gitTreeSha": "354e775b79ea6952ec63acac4d3ee8a9ae07a650", @@ -138,7 +130,6 @@ { "name": "orchestration", "sourcePath": "skills/orchestration", - "appVersion": "1.4.144-rc.4", "releaseRevision": 24, "packageDigest": "9fbfa2ae3f3f99441563a4b8b1c6302107944480db8718ddb326a862a51f7ab9", "gitTreeSha": "086c41e0b353b4908d2963694b4a7c791d4b3982", diff --git a/src/main/ipc/skills.test.ts b/src/main/ipc/skills.test.ts index bc390b2beb7..91c449f751f 100644 --- a/src/main/ipc/skills.test.ts +++ b/src/main/ipc/skills.test.ts @@ -15,6 +15,9 @@ const { })) vi.mock('electron', () => ({ + app: { + getVersion: () => '9.9.9-test' + }, ipcMain: { handle: handleMock } @@ -168,7 +171,10 @@ describe('registerSkillsHandlers', () => { await handler(null) - expect(inventorySkillFreshnessMock).toHaveBeenCalledWith({ repos }) + expect(inventorySkillFreshnessMock).toHaveBeenCalledWith({ + currentAppVersion: '9.9.9-test', + repos + }) expect(getWslHomeMock).not.toHaveBeenCalled() }) }) diff --git a/src/main/ipc/skills.ts b/src/main/ipc/skills.ts index d210123a744..711162927fe 100644 --- a/src/main/ipc/skills.ts +++ b/src/main/ipc/skills.ts @@ -1,4 +1,4 @@ -import { ipcMain } from 'electron' +import { app, ipcMain } from 'electron' import type { Store } from '../persistence' import { discoverSkills } from '../skills/discovery' import type { SkillDiscoveryResult, SkillDiscoveryTarget } from '../../shared/skills' @@ -61,6 +61,9 @@ export function registerSkillsHandlers(store: Store): void { ipcMain.handle('skills:freshnessInventory', async (): Promise => { // Why: the update command targets this machine's global homes. WSL and SSH // inventories stay out until their installer rail has an equivalent proof. - return inventorySkillFreshness({ repos: store.getRepos() }) + return inventorySkillFreshness({ + currentAppVersion: app.getVersion(), + repos: store.getRepos() + }) }) } diff --git a/src/main/skills/skill-bundle-artifacts.test.ts b/src/main/skills/skill-bundle-artifacts.test.ts index f86d5c382d7..cf58caebe09 100644 --- a/src/main/skills/skill-bundle-artifacts.test.ts +++ b/src/main/skills/skill-bundle-artifacts.test.ts @@ -11,6 +11,48 @@ afterEach(async () => { }) describe('skill bundle artifacts', () => { + it('loads the committed schema-2 artifacts and rejects a stamped schema-1 manifest', async () => { + const resourceRoot = await mkdtemp(join(tmpdir(), 'orca-skill-artifacts-')) + temporaryDirectories.push(resourceRoot) + const target = join(resourceRoot, 'skills') + const source = resolve('resources', 'skills') + await mkdir(target, { recursive: true }) + const [manifest, registry, releaseMapping] = await Promise.all( + ['current-manifest.json', 'snapshot-registry.json', 'release-mapping.json'].map((name) => + readFile(join(source, name), 'utf8') + ) + ) + await Promise.all([ + writeFile(join(target, 'current-manifest.json'), manifest), + writeFile(join(target, 'snapshot-registry.json'), registry), + writeFile(join(target, 'release-mapping.json'), releaseMapping) + ]) + + const artifacts = await loadSkillBundleArtifacts(resourceRoot) + expect(artifacts.manifest.schemaVersion).toBe(2) + expect(artifacts.manifest.skills.length).toBeGreaterThan(0) + + const legacyRoot = await mkdtemp(join(tmpdir(), 'orca-skill-artifacts-')) + temporaryDirectories.push(legacyRoot) + const legacyTarget = join(legacyRoot, 'skills') + await mkdir(legacyTarget, { recursive: true }) + const legacyManifest = JSON.parse(manifest) + legacyManifest.schemaVersion = 1 + legacyManifest.appVersion = '1.0.0' + for (const skill of legacyManifest.skills) { + skill.appVersion = '1.0.0' + } + await Promise.all([ + writeFile(join(legacyTarget, 'current-manifest.json'), JSON.stringify(legacyManifest)), + writeFile(join(legacyTarget, 'snapshot-registry.json'), registry), + writeFile(join(legacyTarget, 'release-mapping.json'), releaseMapping) + ]) + + await expect(loadSkillBundleArtifacts(legacyRoot)).rejects.toThrow( + 'Invalid skill bundle manifest' + ) + }) + it('rejects malformed nested release entries before building provenance', async () => { const resourceRoot = await mkdtemp(join(tmpdir(), 'orca-skill-artifacts-')) temporaryDirectories.push(resourceRoot) diff --git a/src/main/skills/skill-bundle-artifacts.ts b/src/main/skills/skill-bundle-artifacts.ts index e7d403442b5..08e33a0eeba 100644 --- a/src/main/skills/skill-bundle-artifacts.ts +++ b/src/main/skills/skill-bundle-artifacts.ts @@ -41,14 +41,12 @@ const snapshotShape = { const knownSnapshotSchema = z.object(snapshotShape).strict() const manifestSchema = z .object({ - schemaVersion: z.literal(1), - appVersion: z.string().min(1), + schemaVersion: z.literal(2), skills: z.array( z .object({ name: z.string().regex(/^[a-z0-9][a-z0-9._-]*$/), sourcePath: z.string().min(1), - appVersion: z.string().min(1), ...snapshotShape }) .strict() @@ -126,7 +124,6 @@ async function readSkillBundleArtifacts(resourceRoot: string): Promise snapshot.releaseRevision === current.releaseRevision && @@ -137,6 +134,8 @@ async function readSkillBundleArtifacts(resourceRoot: string): Promise> = {} for (const release of releaseMapping.releases) { for (const [name, revision] of Object.entries(release.skills)) { @@ -147,10 +146,6 @@ async function readSkillBundleArtifacts(resourceRoot: string): Promise { await test.writeSkill(join(test.homeDir, '.agents', 'skills'), test.oldMarkdown) const inventory = await inventorySkillFreshness({ + currentAppVersion: '2.0.0', homeDir: test.homeDir, repos: [], resourceRoot: test.resourceRoot @@ -130,6 +130,7 @@ describe('read-only skill freshness inventory', () => { ) const inventory = await inventorySkillFreshness({ + currentAppVersion: '2.0.0', homeDir: test.homeDir, repos: [], resourceRoot: test.resourceRoot @@ -151,6 +152,7 @@ describe('read-only skill freshness inventory', () => { ) const inventory = await inventorySkillFreshness({ + currentAppVersion: '2.0.0', homeDir: test.homeDir, repos: [], resourceRoot: test.resourceRoot @@ -176,6 +178,7 @@ describe('read-only skill freshness inventory', () => { await symlink(canonical, join(claudeRoot, 'orca-cli')) const inventory = await inventorySkillFreshness({ + currentAppVersion: '2.0.0', homeDir: test.homeDir, repos: [], resourceRoot: test.resourceRoot @@ -205,6 +208,7 @@ describe('read-only skill freshness inventory', () => { ) const inventory = await inventorySkillFreshness({ + currentAppVersion: '2.0.0', homeDir: test.homeDir, repos, resourceRoot: test.resourceRoot @@ -223,6 +227,7 @@ describe('read-only skill freshness inventory', () => { const inaccessiblePath = join(test.homeDir, '.codex', 'skills', 'orca-cli') const inventory = await inventorySkillFreshness({ + currentAppVersion: '2.0.0', homeDir: test.homeDir, repos: [], resourceRoot: test.resourceRoot, @@ -248,6 +253,7 @@ describe('read-only skill freshness inventory', () => { const inaccessiblePath = join(repoPath, '.agents', 'skills', 'orca-cli') const inventory = await inventorySkillFreshness({ + currentAppVersion: '2.0.0', homeDir: test.homeDir, repos: [{ id: 'repo', path: repoPath }] as unknown as Repo[], resourceRoot: test.resourceRoot, @@ -292,6 +298,7 @@ describe('read-only skill freshness inventory', () => { } const inventory = await inventorySkillFreshness({ + currentAppVersion: '2.0.0', homeDir: test.homeDir, repos, resourceRoot: test.resourceRoot @@ -310,6 +317,7 @@ describe('read-only skill freshness inventory', () => { ) const inventory = await inventorySkillFreshness({ + currentAppVersion: '2.0.0', homeDir: test.homeDir, repos: [], resourceRoot: test.resourceRoot @@ -326,7 +334,10 @@ describe('read-only skill freshness inventory', () => { await writeFile(registryPath, `${JSON.stringify(registry, null, 2)}\n`) await test.writeSkill(join(test.homeDir, '.agents', 'skills'), test.currentMarkdown) + // Why: the injected version deliberately differs from every mapping entry + // to prove current placements are labeled by the running build, not history. const inventory = await inventorySkillFreshness({ + currentAppVersion: '2.1.0-unreleased', homeDir: test.homeDir, repos: [], resourceRoot: test.resourceRoot @@ -335,7 +346,8 @@ describe('read-only skill freshness inventory', () => { expect(inventory.installations[0]).toMatchObject({ status: 'current', installedReleaseRevision: 2, - installedAppVersion: '2.0.0' + installedAppVersion: '2.1.0-unreleased', + currentAppVersion: '2.1.0-unreleased' }) }) @@ -348,6 +360,7 @@ describe('read-only skill freshness inventory', () => { ) const inventory = await inventorySkillFreshness({ + currentAppVersion: '2.0.0', homeDir: test.homeDir, repos, resourceRoot: test.resourceRoot diff --git a/src/main/skills/skill-freshness-inventory.ts b/src/main/skills/skill-freshness-inventory.ts index e5bbae31da9..8b1c5c19094 100644 --- a/src/main/skills/skill-freshness-inventory.ts +++ b/src/main/skills/skill-freshness-inventory.ts @@ -30,15 +30,16 @@ export function boundRepositorySkillRoots(roots: readonly SkillScanRoot[]): { } } -export async function inventorySkillFreshness( - args: { - homeDir?: string - cwd?: string - repos?: Repo[] - resourceRoot?: string - candidateLstat?: CandidateLstat - } = {} -): Promise { +export async function inventorySkillFreshness(args: { + // Why: the bundled artifacts are content-only; the running build supplies + // its own version here so current placements can be labeled honestly. + currentAppVersion: string + homeDir?: string + cwd?: string + repos?: Repo[] + resourceRoot?: string + candidateLstat?: CandidateLstat +}): Promise { const artifacts = await loadSkillBundleArtifacts(args.resourceRoot) const currentByName = new Map(artifacts.manifest.skills.map((skill) => [skill.name, skill])) const discoveryArgs = { @@ -65,6 +66,7 @@ export async function inventorySkillFreshness( classifyHomeSkillCandidate({ root, current, + currentAppVersion: args.currentAppVersion, artifacts, canonicalRootPath, candidateLstat: args.candidateLstat ?? ((path) => lstat(path)) @@ -84,6 +86,7 @@ export async function inventorySkillFreshness( classifyUnsupportedSkillCandidate({ root, current, + currentAppVersion: args.currentAppVersion, artifacts, unresolvedPath: join(root.path, current.name), candidateLstat @@ -99,6 +102,7 @@ export async function inventorySkillFreshness( (current) => () => observeSkillFreshnessInstallation({ current, + currentAppVersion: args.currentAppVersion, artifacts, rootId: 'repo-scan-limit', providers: [...new Set(omittedRepoRoots.flatMap((root) => root.providers))], @@ -128,6 +132,7 @@ export async function inventorySkillFreshness( classifyUnsupportedSkillCandidate({ root, current, + currentAppVersion: args.currentAppVersion, artifacts, unresolvedPath: candidate.path, candidateLstat @@ -142,6 +147,7 @@ export async function inventorySkillFreshness( (current) => () => observeSkillFreshnessInstallation({ current, + currentAppVersion: args.currentAppVersion, artifacts, rootId: root.id, providers: root.providers, diff --git a/src/main/skills/skill-freshness-placement-observation.ts b/src/main/skills/skill-freshness-placement-observation.ts index 2f8d32ca663..4218a4cc316 100644 --- a/src/main/skills/skill-freshness-placement-observation.ts +++ b/src/main/skills/skill-freshness-placement-observation.ts @@ -57,6 +57,7 @@ function knownSnapshots( export async function observeSkillFreshnessInstallation(args: { current: SkillCurrentBundleEntry + currentAppVersion: string artifacts: SkillBundleArtifacts rootId: string providers: SkillFreshnessInstallation['providers'] @@ -78,7 +79,7 @@ export async function observeSkillFreshnessInstallation(args: { topology: args.topology.topology, currentReleaseRevision: args.current.releaseRevision, currentPackageDigest: args.current.packageDigest, - currentAppVersion: args.current.appVersion, + currentAppVersion: args.currentAppVersion, errorCategory: args.topology.errorCategory } if (!args.topology.resolvedPath || !args.topology.identity) { @@ -105,9 +106,14 @@ export async function observeSkillFreshnessInstallation(args: { ...base, status: freshnessStatus(snapshot, args.current), installedReleaseRevision: snapshot?.releaseRevision ?? null, + // Why: the current revision may be unreleased (or trail the newest tag), + // so its label is the running build's version; only historical revisions + // resolve through the release mapping. installedAppVersion: snapshot - ? (args.artifacts.releasedAppVersions[args.current.name]?.[snapshot.releaseRevision] ?? - null) + ? snapshot.releaseRevision === args.current.releaseRevision + ? args.currentAppVersion + : (args.artifacts.releasedAppVersions[args.current.name]?.[snapshot.releaseRevision] ?? + null) : null, observedPackageDigest: observed.observedDigest } @@ -126,6 +132,7 @@ export async function observeSkillFreshnessInstallation(args: { export async function classifyHomeSkillCandidate(args: { root: SkillScanRoot current: SkillCurrentBundleEntry + currentAppVersion: string artifacts: SkillBundleArtifacts canonicalRootPath: string candidateLstat: CandidateLstat @@ -139,6 +146,7 @@ export async function classifyHomeSkillCandidate(args: { } return observeSkillFreshnessInstallation({ current: args.current, + currentAppVersion: args.currentAppVersion, artifacts: args.artifacts, rootId: args.root.id, providers: args.root.providers, @@ -167,6 +175,7 @@ export async function classifyHomeSkillCandidate(args: { } return observeSkillFreshnessInstallation({ current: args.current, + currentAppVersion: args.currentAppVersion, artifacts: args.artifacts, rootId: args.root.id, providers: args.root.providers, @@ -180,6 +189,7 @@ export async function classifyHomeSkillCandidate(args: { export async function classifyUnsupportedSkillCandidate(args: { root: SkillScanRoot current: SkillCurrentBundleEntry + currentAppVersion: string artifacts: SkillBundleArtifacts unresolvedPath: string candidateLstat: CandidateLstat @@ -192,6 +202,7 @@ export async function classifyUnsupportedSkillCandidate(args: { } return observeSkillFreshnessInstallation({ current: args.current, + currentAppVersion: args.currentAppVersion, artifacts: args.artifacts, rootId: args.root.id, providers: args.root.providers, @@ -208,6 +219,7 @@ export async function classifyUnsupportedSkillCandidate(args: { } return observeSkillFreshnessInstallation({ current: args.current, + currentAppVersion: args.currentAppVersion, artifacts: args.artifacts, rootId: args.root.id, providers: args.root.providers, diff --git a/src/shared/skill-freshness.ts b/src/shared/skill-freshness.ts index 08383d5e5f5..0a18eca1a81 100644 --- a/src/shared/skill-freshness.ts +++ b/src/shared/skill-freshness.ts @@ -20,12 +20,12 @@ export type SkillKnownSnapshot = { export type SkillCurrentBundleEntry = SkillKnownSnapshot & { name: string sourcePath: string - appVersion: string } +// Why: schema 2 removed the stamped app version so the committed artifact is a +// pure function of skills/ content; the running build supplies its own version. export type SkillBundleManifest = { - schemaVersion: 1 - appVersion: string + schemaVersion: 2 skills: SkillCurrentBundleEntry[] } From 1b331f282cd4da880b888f242e3545fffbba4cb5 Mon Sep 17 00:00:00 2001 From: Guillermo Avelar <116450483+ghee-yeh@users.noreply.github.com> Date: Thu, 16 Jul 2026 23:10:15 -0500 Subject: [PATCH 19/24] feat(editor): bindable keyboard shortcut to add a markdown review note (Mod+Alt+N) (#8250) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(editor): bindable shortcut to add a markdown review note Adds editor.addReviewNote (default Mod+Alt+N) to the shared keybinding registry and wires it into all three markdown surfaces: the rich editor key handler invokes the annotation popover opener, the Monaco editor installs a keydown listener that opens the composer for the tracked selection target, and the preview maps the DOM selection to its annotation block. openAnnotationPopover now prefers the live selection target over synced state so the shortcut works even before the sync render lands. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01XYtipbTz8N4woN1sxTK1ia * fix(editor): cover list items and Monaco path for add-review-note shortcut Tag the preview's list-item annotation blocks with data-annotation-block-key so the shortcut resolves selections inside li blocks (review feedback), and extend the e2e spec to drive the Monaco source-editor wiring. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01XYtipbTz8N4woN1sxTK1ia * docs(e2e): explain store-driven view-mode switch in add-review-note spec Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01XYtipbTz8N4woN1sxTK1ia * refactor(editor): extract add-review-note + selection-flush modules to satisfy max-lines after rebase * refactor(editor): spread key-handler params and extract TOC hook to satisfy max-lines * test(editor): move add-review-note installer test into its own describe * fix(editor): pass add-review-note chord through when Monaco cannot act; cover preview surface e2e * fix(editor): unify add-review-note chord consumption — consume only when a composer opens * fix(editor): gate list-item annotation block key on composer availability * fix(editor): require live selection for keyboard add-review-note * chore: retrigger CI against current main (merge ref built during transient main breakage at 6e91ca6c0) --------- Co-authored-by: Claude Fable 5 Co-authored-by: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> --- .../src/components/editor/MarkdownPreview.tsx | 35 +++- .../src/components/editor/MonacoEditor.tsx | 23 ++- .../components/editor/RichMarkdownEditor.tsx | 40 +--- .../editor/editor-shortcuts.test.ts | 69 +++++++ .../src/components/editor/editor-shortcuts.ts | 21 ++ ...rkdown-preview-annotation-shortcut.test.ts | 95 +++++++++ .../markdown-preview-annotation-shortcut.ts | 38 ++++ .../rich-markdown-annotation-shortcut.ts | 21 ++ .../rich-markdown-editor-config.test.ts | 1 + .../editor/rich-markdown-editor-config.ts | 47 +---- .../editor/rich-markdown-key-handler.test.ts | 38 ++++ .../editor/rich-markdown-key-handler.ts | 47 +---- .../editor/rich-markdown-selection-flush.ts | 42 ++++ .../rich-markdown-tab-key-handler.test.ts | 1 + .../use-rich-markdown-table-of-contents.ts | 44 +++++ .../editor/useRichMarkdownReviewController.ts | 78 ++++---- src/shared/keybindings.test.ts | 10 + src/shared/keybindings.ts | 9 + .../markdown-add-review-note-shortcut.spec.ts | 184 ++++++++++++++++++ 19 files changed, 688 insertions(+), 155 deletions(-) create mode 100644 src/renderer/src/components/editor/markdown-preview-annotation-shortcut.test.ts create mode 100644 src/renderer/src/components/editor/markdown-preview-annotation-shortcut.ts create mode 100644 src/renderer/src/components/editor/rich-markdown-annotation-shortcut.ts create mode 100644 src/renderer/src/components/editor/rich-markdown-selection-flush.ts create mode 100644 src/renderer/src/components/editor/use-rich-markdown-table-of-contents.ts create mode 100644 tests/e2e/markdown-add-review-note-shortcut.spec.ts diff --git a/src/renderer/src/components/editor/MarkdownPreview.tsx b/src/renderer/src/components/editor/MarkdownPreview.tsx index 3b4c53c3fbc..6e7a80e67b1 100644 --- a/src/renderer/src/components/editor/MarkdownPreview.tsx +++ b/src/renderer/src/components/editor/MarkdownPreview.tsx @@ -68,6 +68,10 @@ import { isMarkdownPreviewFindShortcut, setActiveMarkdownPreviewSearchMatch } from './markdown-preview-search' +import { + getMarkdownAnnotationBlockKeyForSelection, + isMarkdownPreviewAddReviewNoteShortcut +} from './markdown-preview-annotation-shortcut' import { usePreserveSectionDuringExternalEdit } from './usePreserveSectionDuringExternalEdit' import { openHttpLink, type HttpLinkSourceOwner } from '@/lib/http-link-routing' import { getShortcutPlatform } from '@/lib/shortcut-platform' @@ -916,6 +920,20 @@ export default function MarkdownPreview({ return } + if ( + isMarkdownPreviewAddReviewNoteShortcut(event, getShortcutPlatform(), keybindings) && + targetInsidePreview && + markdownAnnotationsEnabled + ) { + const blockKey = getMarkdownAnnotationBlockKeyForSelection(root, window.getSelection()) + if (blockKey) { + event.preventDefault() + event.stopPropagation() + setActiveAnnotationBlockKey(blockKey) + } + return + } + if (!isSearchOpen) { return } @@ -930,7 +948,7 @@ export default function MarkdownPreview({ window.addEventListener('keydown', handleKeyDown, { capture: true }) return () => window.removeEventListener('keydown', handleKeyDown, { capture: true }) - }, [closeSearch, isSearchOpen, keybindings, openSearch]) + }, [closeSearch, isSearchOpen, keybindings, markdownAnnotationsEnabled, openSearch]) const handleCopyMarkdownReviewNotes = useCallback(async (): Promise => { if (markdownReviewNotes.length === 0) { @@ -1248,6 +1266,7 @@ export default function MarkdownPreview({ className={`markdown-annotation-block ${hasReviewNotes ? 'has-review-notes' : ''}`.trim()} data-source-line={range.startLine} data-source-end-line={range.endLine} + data-annotation-block-key={blockKey} onClick={(event) => handleAnnotatedMarkdownBlockClick(range, event)} > {rendered} @@ -1668,6 +1687,11 @@ export default function MarkdownPreview({ } const blockKey = `li:${range.startLine}-${range.endLine}` const hasReviewNotes = getMarkdownCommentsForRange(range).length > 0 + const controls = renderAnnotationControls( + range, + blockKey, + getMarkdownPreviewAnnotationQuote(children) + ) return (
  • handleAnnotatedMarkdownBlockClick(range, event)} > {children} - {renderAnnotationControls( - range, - blockKey, - getMarkdownPreviewAnnotationQuote(children) - )} + {controls}
  • ) diff --git a/src/renderer/src/components/editor/MonacoEditor.tsx b/src/renderer/src/components/editor/MonacoEditor.tsx index daf288d8ce9..a1945794b8b 100644 --- a/src/renderer/src/components/editor/MonacoEditor.tsx +++ b/src/renderer/src/components/editor/MonacoEditor.tsx @@ -48,7 +48,11 @@ import { getDiffCommentPopoverTop } from '../diff-comments/diff-comment-popover-position' import { isLinuxUserAgent } from '../terminal-pane/pane-helpers' -import { installEditorSaveShortcut, installMonacoEditorFindShortcut } from './editor-shortcuts' +import { + installEditorAddReviewNoteShortcut, + installEditorSaveShortcut, + installMonacoEditorFindShortcut +} from './editor-shortcuts' import { Plus } from 'lucide-react' import { getMonacoMarkdownSelectionAnnotationTarget, @@ -198,6 +202,10 @@ export default function MonacoEditor({ const [commentPopover, setCommentPopover] = useState(null) const [selectionAnnotationTarget, setSelectionAnnotationTarget] = useState(null) + // Why: the Monaco mount closure installs its keydown listeners once, so the + // add-review-note shortcut reads the live selection target through a ref. + const selectionAnnotationTargetRef = useRef(null) + selectionAnnotationTargetRef.current = selectionAnnotationTarget const isDark = settings?.theme === 'dark' || (settings?.theme === 'system' && window.matchMedia('(prefers-color-scheme: dark)').matches) @@ -413,6 +421,18 @@ export default function MonacoEditor({ propsRef.current.onSave(value) }) const cleanupFindShortcut = installMonacoEditorFindShortcut(editorInstance) + // Opens the same composer as the selection "+" button; the target ref + // mirrors the last-rendered selection target and is null unless markdown + // annotations are enabled and text is selected. + const cleanupAddReviewNoteShortcut = installEditorAddReviewNoteShortcut(editorDomNode, () => { + const target = selectionAnnotationTargetRef.current + if (!target) { + return false + } + setCommentPopover(target) + setSelectionAnnotationTarget(null) + return true + }) const searchInFilesAction = editorInstance.addAction({ id: 'orca.searchInFiles', label: translate('auto.components.editor.MonacoEditor.fd68ae03b3', 'Search in Files'), @@ -512,6 +532,7 @@ export default function MonacoEditor({ gutterMouseDownSub.dispose() cleanupSaveShortcut() cleanupFindShortcut() + cleanupAddReviewNoteShortcut() editorDomNode.removeEventListener('paste', onLargeTextPaste, { capture: true }) searchInFilesAction.dispose() autoHeightSub?.dispose() diff --git a/src/renderer/src/components/editor/RichMarkdownEditor.tsx b/src/renderer/src/components/editor/RichMarkdownEditor.tsx index fdbaee0dfcb..2f499ba6b65 100644 --- a/src/renderer/src/components/editor/RichMarkdownEditor.tsx +++ b/src/renderer/src/components/editor/RichMarkdownEditor.tsx @@ -1,4 +1,4 @@ -import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import React, { useCallback, useEffect, useRef, useState } from 'react' import { useEditorState, type Editor } from '@tiptap/react' import type { DiffComment, MarkdownDocument } from '../../../../shared/types' import { useAppStore } from '@/store' @@ -10,9 +10,7 @@ import { useLinkBubble } from './useLinkBubble' import { useEditorScrollRestore } from './useEditorScrollRestore' import { useModifierHeldClass } from './useModifierHeldClass' import { registerPendingEditorFlush } from './editor-pending-flush' -import type { MarkdownTocItem } from './markdown-table-of-contents' -import { findRichMarkdownTocHeadingTarget } from './rich-markdown-toc-heading-target' -import { selectMarkdownTableOfContents } from './markdown-toc-visibility-gate' +import { useRichMarkdownTableOfContents } from './use-rich-markdown-table-of-contents' import { RichMarkdownEditorSurface } from './RichMarkdownEditorSurface' import { useRichMarkdownEditorInstance } from './useRichMarkdownEditorInstance' import { useRichMarkdownMenuController } from './useRichMarkdownMenuController' @@ -56,10 +54,6 @@ type RichMarkdownEditorProps = { headerSlot?: React.ReactNode } -function flattenMarkdownTocItems(items: MarkdownTocItem[]): MarkdownTocItem[] { - return items.flatMap((item) => [item, ...flattenMarkdownTocItems(item.children)]) -} - export default function RichMarkdownEditor({ fileId, content, @@ -112,6 +106,7 @@ export default function RichMarkdownEditor({ const onOpenDocLinkRef = useRef(onOpenDocLink) const handleLocalImagePickRef = useRef<() => void>(() => {}) const openSearchRef = useRef<() => void>(() => {}) + const openAnnotationPopoverRef = useRef<(requireLiveSelection?: boolean) => boolean>(() => false) // Why: ProseMirror keeps the initial handleKeyDown closure, so `editor` stays // stuck at the first-render null value unless we read the live instance here. const editorRef = useRef(null) @@ -145,17 +140,10 @@ export default function RichMarkdownEditor({ worktreeId, worktreeRoot }) - // Why: building the table of contents runs a full-document remark parse on - // every content change. The result is only used while the panel is open - // (closed by default), so gate the parse on visibility; including - // showTableOfContents in deps rebuilds the outline the moment it opens. - const tableOfContentsItems = useMemo( - () => selectMarkdownTableOfContents(showTableOfContents, content), - [content, showTableOfContents] - ) - const flatTableOfContentsItems = useMemo( - () => flattenMarkdownTocItems(tableOfContentsItems), - [tableOfContentsItems] + const { tableOfContentsItems, navigateToTableOfContentsItem } = useRichMarkdownTableOfContents( + showTableOfContents, + content, + scrollContainerRef ) // Why: assigning callback refs during render keeps them current before any @@ -166,6 +154,7 @@ export default function RichMarkdownEditor({ onSaveRef.current = onSave onOpenDocLinkRef.current = onOpenDocLink isEditingLinkRef.current = isEditingLink + openAnnotationPopoverRef.current = review.openAnnotationPopover const reconcileRoundTripRef = useRichMarkdownReconcileRoundTrip({ htmlSuperscriptLinkContext, filePath, @@ -258,6 +247,7 @@ export default function RichMarkdownEditor({ markdownSourceLineOffsetRef: review.markdownSourceLineOffsetRef, flushPendingSerialization, openSearchRef, + openAnnotationPopoverRef, syncAnnotationTarget: review.syncAnnotationTarget, clearAnnotationTarget: review.clearAnnotationTarget, scrollRichMarkdownReviewNoteCardIntoView: review.scrollRichMarkdownReviewNoteCardIntoView, @@ -360,18 +350,6 @@ export default function RichMarkdownEditor({ }) openSearchRef.current = openSearch - const navigateToTableOfContentsItem = useCallback( - (id: string): void => { - const container = scrollContainerRef.current - if (!container) { - return - } - const heading = findRichMarkdownTocHeadingTarget(container, flatTableOfContentsItems, id) - heading?.scrollIntoView({ block: 'center' }) - }, - [flatTableOfContentsItems] - ) - return ( ({ })) import { + installEditorAddReviewNoteShortcut, installEditorFindShortcut, installMonacoDiffChangeNavigationShortcut, installMonacoEditorFindShortcut @@ -201,6 +202,74 @@ describe('installEditorFindShortcut', () => { }) }) +describe('installEditorAddReviewNoteShortcut', () => { + it('invokes add-review-note on its default binding and honors overrides', () => { + const container = document.createElement('div') + const input = document.createElement('textarea') + const onAddReviewNote = vi.fn(() => true) + container.appendChild(input) + document.body.appendChild(container) + const dispose = installEditorAddReviewNoteShortcut(container, onAddReviewNote) + + const defaultEvent = dispatchKeyDown(input, { + key: 'n', + code: 'KeyN', + metaKey: true, + altKey: true + }) + const repeatEvent = dispatchKeyDown(input, { + key: 'n', + code: 'KeyN', + metaKey: true, + altKey: true, + repeat: true + }) + const unrelatedEvent = dispatchKeyDown(input, { key: 'n', code: 'KeyN', metaKey: true }) + + expect(defaultEvent.defaultPrevented).toBe(true) + expect(repeatEvent.defaultPrevented).toBe(false) + expect(unrelatedEvent.defaultPrevented).toBe(false) + expect(onAddReviewNote).toHaveBeenCalledTimes(1) + + shortcutState.keybindings = { 'editor.addReviewNote': ['Mod+Shift+A'] } + const overriddenEvent = dispatchKeyDown(input, { + key: 'a', + code: 'KeyA', + metaKey: true, + shiftKey: true + }) + expect(overriddenEvent.defaultPrevented).toBe(true) + expect(onAddReviewNote).toHaveBeenCalledTimes(2) + + dispose() + dispatchKeyDown(input, { key: 'a', code: 'KeyA', metaKey: true, shiftKey: true }) + expect(onAddReviewNote).toHaveBeenCalledTimes(2) + }) + + it('leaves the chord unconsumed when the handler reports it did not act', () => { + const container = document.createElement('div') + const input = document.createElement('textarea') + const onDownstreamKeyDown = vi.fn() + const onAddReviewNote = vi.fn(() => false) + container.appendChild(input) + document.body.appendChild(container) + input.addEventListener('keydown', onDownstreamKeyDown) + const dispose = installEditorAddReviewNoteShortcut(container, onAddReviewNote) + + const event = dispatchKeyDown(input, { + key: 'n', + code: 'KeyN', + metaKey: true, + altKey: true + }) + + expect(onAddReviewNote).toHaveBeenCalledTimes(1) + expect(event.defaultPrevented).toBe(false) + expect(onDownstreamKeyDown).toHaveBeenCalledTimes(1) + dispose() + }) +}) + describe('installMonacoDiffChangeNavigationShortcut', () => { function createDiffNavigationFixture(): { container: HTMLDivElement diff --git a/src/renderer/src/components/editor/editor-shortcuts.ts b/src/renderer/src/components/editor/editor-shortcuts.ts index 825caf6f1ee..5038498a561 100644 --- a/src/renderer/src/components/editor/editor-shortcuts.ts +++ b/src/renderer/src/components/editor/editor-shortcuts.ts @@ -79,6 +79,27 @@ export function installMonacoDiffChangeNavigationShortcut( return () => target.removeEventListener('keydown', handleKeyDown, true) } +export function installEditorAddReviewNoteShortcut( + target: HTMLElement, + onAddReviewNote: () => boolean +): () => void { + const handleKeyDown = (event: KeyboardEvent): void => { + if (event.repeat || !editorShortcutMatches('editor.addReviewNote', event)) { + return + } + // Why: only consume the chord when a composer actually opens; on files + // where review notes can never apply the key must stay available to + // whatever else the user bound it to. + if (onAddReviewNote()) { + event.preventDefault() + event.stopPropagation() + } + } + + target.addEventListener('keydown', handleKeyDown, true) + return () => target.removeEventListener('keydown', handleKeyDown, true) +} + type MonacoFindShortcutEditor = { getAction: (id: string) => { run: () => void | Promise } | null getContainerDomNode: () => HTMLElement diff --git a/src/renderer/src/components/editor/markdown-preview-annotation-shortcut.test.ts b/src/renderer/src/components/editor/markdown-preview-annotation-shortcut.test.ts new file mode 100644 index 00000000000..ed16221dfbb --- /dev/null +++ b/src/renderer/src/components/editor/markdown-preview-annotation-shortcut.test.ts @@ -0,0 +1,95 @@ +// @vitest-environment happy-dom + +import { afterEach, describe, expect, it } from 'vitest' +import { + getMarkdownAnnotationBlockKeyForSelection, + isMarkdownPreviewAddReviewNoteShortcut +} from './markdown-preview-annotation-shortcut' + +function createPreviewFixture(): { + root: HTMLDivElement + block: HTMLDivElement + paragraph: HTMLParagraphElement +} { + const root = document.createElement('div') + const block = document.createElement('div') + block.className = 'markdown-annotation-block' + block.setAttribute('data-annotation-block-key', 'p:3-5') + const paragraph = document.createElement('p') + paragraph.textContent = 'Some rendered markdown text' + block.appendChild(paragraph) + root.appendChild(block) + document.body.appendChild(root) + return { root, block, paragraph } +} + +function selectTextIn(node: Node): Selection { + const selection = window.getSelection() + if (!selection) { + throw new Error('Selection API unavailable in test environment') + } + const range = document.createRange() + range.selectNodeContents(node) + selection.removeAllRanges() + selection.addRange(range) + return selection +} + +afterEach(() => { + window.getSelection()?.removeAllRanges() + document.body.replaceChildren() +}) + +describe('getMarkdownAnnotationBlockKeyForSelection', () => { + it('returns the block key for a selection inside an annotation block', () => { + const { root, paragraph } = createPreviewFixture() + const selection = selectTextIn(paragraph) + + expect(getMarkdownAnnotationBlockKeyForSelection(root, selection)).toBe('p:3-5') + }) + + it('returns null for a collapsed selection', () => { + const { root, paragraph } = createPreviewFixture() + const selection = selectTextIn(paragraph) + selection.collapseToStart() + + expect(getMarkdownAnnotationBlockKeyForSelection(root, selection)).toBeNull() + }) + + it('returns null when the selection is outside the preview root', () => { + const { root } = createPreviewFixture() + const outside = document.createElement('p') + outside.textContent = 'other text' + document.body.appendChild(outside) + const selection = selectTextIn(outside) + + expect(getMarkdownAnnotationBlockKeyForSelection(root, selection)).toBeNull() + }) + + it('returns null without a selection', () => { + const { root } = createPreviewFixture() + + expect(getMarkdownAnnotationBlockKeyForSelection(root, null)).toBeNull() + }) +}) + +describe('isMarkdownPreviewAddReviewNoteShortcut', () => { + it('matches the default binding and respects overrides', () => { + const defaultEvent = { + key: 'n', + code: 'KeyN', + metaKey: true, + ctrlKey: false, + altKey: true, + shiftKey: false + } + + expect(isMarkdownPreviewAddReviewNoteShortcut(defaultEvent, 'darwin')).toBe(true) + expect(isMarkdownPreviewAddReviewNoteShortcut(defaultEvent, 'linux')).toBe(false) + expect( + isMarkdownPreviewAddReviewNoteShortcut(defaultEvent, 'darwin', { + 'editor.addReviewNote': ['Mod+Shift+A'] + }) + ).toBe(false) + }) +}) diff --git a/src/renderer/src/components/editor/markdown-preview-annotation-shortcut.ts b/src/renderer/src/components/editor/markdown-preview-annotation-shortcut.ts new file mode 100644 index 00000000000..b64dc4c67f5 --- /dev/null +++ b/src/renderer/src/components/editor/markdown-preview-annotation-shortcut.ts @@ -0,0 +1,38 @@ +import { keybindingMatchesAction, type KeybindingOverrides } from '../../../../shared/keybindings' + +export function isMarkdownPreviewAddReviewNoteShortcut( + event: Pick, + platform: NodeJS.Platform, + keybindings?: KeybindingOverrides +): boolean { + return keybindingMatchesAction('editor.addReviewNote', event, platform, keybindings) +} + +function closestAnnotationBlockKey(node: Node | null, root: HTMLElement): string | null { + const element = node instanceof Element ? node : (node?.parentElement ?? null) + const block = element?.closest('[data-annotation-block-key]') ?? null + if (!block || !root.contains(block)) { + return null + } + return block.getAttribute('data-annotation-block-key') +} + +/** + * Maps the current DOM text selection to the annotation block that should host + * the review-note composer. Returns null when the selection is collapsed or + * falls outside an annotatable block of this preview root. + */ +export function getMarkdownAnnotationBlockKeyForSelection( + root: HTMLElement, + selection: Selection | null +): string | null { + if (!selection || selection.isCollapsed || selection.rangeCount === 0) { + return null + } + // Why: a selection spanning multiple blocks anchors the composer on the + // block where the selection started, falling back to where it ended. + return ( + closestAnnotationBlockKey(selection.anchorNode, root) ?? + closestAnnotationBlockKey(selection.focusNode, root) + ) +} diff --git a/src/renderer/src/components/editor/rich-markdown-annotation-shortcut.ts b/src/renderer/src/components/editor/rich-markdown-annotation-shortcut.ts new file mode 100644 index 00000000000..318154d412e --- /dev/null +++ b/src/renderer/src/components/editor/rich-markdown-annotation-shortcut.ts @@ -0,0 +1,21 @@ +import type { KeyHandlerContext } from './rich-markdown-key-handler' +import { editorShortcutMatches } from './editor-shortcuts' + +/** + * Mod+Alt+N: open the review-note composer for the current selection. + */ +export function handleRichMarkdownAddReviewNoteShortcut( + ctx: KeyHandlerContext, + event: KeyboardEvent +): boolean { + if (!editorShortcutMatches('editor.addReviewNote', event)) { + return false + } + // Why: require the live selection so a collapsed selection cannot reopen a + // stale target; consume the chord only when a composer actually opens. + if (!ctx.openAnnotationPopoverRef.current(true)) { + return false + } + event.preventDefault() + return true +} diff --git a/src/renderer/src/components/editor/rich-markdown-editor-config.test.ts b/src/renderer/src/components/editor/rich-markdown-editor-config.test.ts index 8573ddea973..dd58ff40838 100644 --- a/src/renderer/src/components/editor/rich-markdown-editor-config.test.ts +++ b/src/renderer/src/components/editor/rich-markdown-editor-config.test.ts @@ -73,6 +73,7 @@ function createConfigParams(overrides: Partial = {}): Editor markdownSourceLineOffsetRef: ref(0), flushPendingSerialization: vi.fn(), openSearchRef: ref(vi.fn()), + openAnnotationPopoverRef: ref(vi.fn()), syncAnnotationTarget: vi.fn(), clearAnnotationTarget: vi.fn(), scrollRichMarkdownReviewNoteCardIntoView: vi.fn(), diff --git a/src/renderer/src/components/editor/rich-markdown-editor-config.ts b/src/renderer/src/components/editor/rich-markdown-editor-config.ts index ec94eda94d4..5877d9eb31e 100644 --- a/src/renderer/src/components/editor/rich-markdown-editor-config.ts +++ b/src/renderer/src/components/editor/rich-markdown-editor-config.ts @@ -77,6 +77,7 @@ export type EditorConfigParams = { markdownSourceLineOffsetRef: MutableRefObject flushPendingSerialization: () => void openSearchRef: MutableRefObject<() => void> + openAnnotationPopoverRef: MutableRefObject<(requireLiveSelection?: boolean) => boolean> syncAnnotationTarget: (editor: Editor) => void clearAnnotationTarget: () => void scrollRichMarkdownReviewNoteCardIntoView: (commentId: string) => void @@ -109,17 +110,7 @@ export function createRichMarkdownEditorConfig(params: EditorConfigParams): UseE reconcileRoundTripRef, onContentChangeRef, onDirtyStateHintRef, - onSaveRef, onOpenDocLinkRef, - isEditingLinkRef, - slashMenuRef, - filteredSlashCommandsRef, - selectedCommandIndexRef, - docLinkMenuRef, - filteredDocLinkRowsRef, - selectedDocLinkIndexRef, - handleLocalImagePickRef, - handleEmojiPickRef, typedEmptyOrderedListMarkerRef, cancelAutoFocusRef, serializeTimerRef, @@ -127,15 +118,11 @@ export function createRichMarkdownEditorConfig(params: EditorConfigParams): UseE isApplyingProgrammaticUpdateRef, markdownCommentsRef, markdownSourceLineOffsetRef, - flushPendingSerialization, - openSearchRef, syncAnnotationTarget, clearAnnotationTarget, scrollRichMarkdownReviewNoteCardIntoView, setIsEditingLink, setLinkBubble, - setSelectedCommandIndex, - setSelectedDocLinkIndex, setSlashMenu, setDocLinkMenu } = params @@ -172,36 +159,12 @@ export function createRichMarkdownEditorConfig(params: EditorConfigParams): UseE typedEmptyOrderedListMarkerRef.current = /^\d+\.$/.test(beforeCursor) return false }, + // Why: KeyHandlerContext is a typed subset of EditorConfigParams, so the + // spread stays type-checked while new context fields avoid re-listing + // every ref here. handleKeyDown: createRichMarkdownKeyHandler({ - isMac, - editorRef, - rootRef, - lastCommittedMarkdownRef, - originalSourceRef, - baseCanonicalRef, - reconcileRoundTripRef, - onContentChangeRef, - onSaveRef, - isEditingLinkRef, - slashMenuRef, - filteredSlashCommandsRef, - selectedCommandIndexRef, - docLinkMenuRef, - filteredDocLinkRowsRef, - selectedDocLinkIndexRef, - handleLocalImagePickRef, - handleEmojiPickRef, - typedEmptyOrderedListMarkerRef, - flushPendingSerialization, - openSearchRef, + ...params, linkBubbleOwnerId: codec.transport.key, - htmlSuperscriptLinkContext, - setIsEditingLink, - setLinkBubble, - setSelectedCommandIndex, - setSelectedDocLinkIndex, - setSlashMenu, - setDocLinkMenu, openSelectedHtmlSuperscriptLink: () => openSelectedHtmlSuperscriptLink({ activateMarkdownLink, diff --git a/src/renderer/src/components/editor/rich-markdown-key-handler.test.ts b/src/renderer/src/components/editor/rich-markdown-key-handler.test.ts index 02d53e1b626..ebd71f84f31 100644 --- a/src/renderer/src/components/editor/rich-markdown-key-handler.test.ts +++ b/src/renderer/src/components/editor/rich-markdown-key-handler.test.ts @@ -4,6 +4,12 @@ import StarterKit from '@tiptap/starter-kit' import { createIsolatedMarkdownExtensionForTests } from './isolated-markdown-extension-for-tests' import { createRichMarkdownKeyHandler, type KeyHandlerContext } from './rich-markdown-key-handler' +// Why: keybinding matching resolves the platform from navigator.userAgent, +// which is environment-dependent under vitest; pin it for determinism. +vi.mock('@/lib/shortcut-platform', () => ({ + getShortcutPlatform: () => 'darwin' as NodeJS.Platform +})) + const extensions = [StarterKit, createIsolatedMarkdownExtensionForTests()] function createEditor(content: object): Editor { @@ -82,6 +88,7 @@ function createContext(editor: Editor, typedMarker: boolean): KeyHandlerContext subscribe: () => () => {}, update: () => {} }, + openAnnotationPopoverRef: { current: vi.fn(() => true) }, setIsEditingLink: vi.fn(), setLinkBubble: vi.fn(), setSelectedCommandIndex: vi.fn(), @@ -105,6 +112,37 @@ function emptyTopLevelOrderedList(): object { } describe('rich markdown key handler', () => { + it('opens the review-note composer on the add-review-note shortcut', () => { + const editor = createEditor(emptyTopLevelOrderedList()) + + try { + const ctx = createContext(editor, false) + const event = keyEvent('n', { metaKey: true, altKey: true, code: 'KeyN' }) + + expect(createRichMarkdownKeyHandler(ctx)(null, event)).toBe(true) + expect(event.preventDefault).toHaveBeenCalled() + expect(ctx.openAnnotationPopoverRef.current).toHaveBeenCalledWith(true) + } finally { + editor.destroy() + } + }) + + it('leaves the add-review-note chord unconsumed when no composer opens', () => { + const editor = createEditor(emptyTopLevelOrderedList()) + + try { + const ctx = createContext(editor, false) + ctx.openAnnotationPopoverRef.current = vi.fn(() => false) + const event = keyEvent('n', { metaKey: true, altKey: true, code: 'KeyN' }) + + expect(createRichMarkdownKeyHandler(ctx)(null, event)).toBe(false) + expect(event.preventDefault).not.toHaveBeenCalled() + expect(ctx.openAnnotationPopoverRef.current).toHaveBeenCalledTimes(1) + } finally { + editor.destroy() + } + }) + it('preserves a typed empty ordered-list shortcut on Enter', () => { const editor = createEditor(emptyTopLevelOrderedList()) diff --git a/src/renderer/src/components/editor/rich-markdown-key-handler.ts b/src/renderer/src/components/editor/rich-markdown-key-handler.ts index d8995f3c913..b0a223887b5 100644 --- a/src/renderer/src/components/editor/rich-markdown-key-handler.ts +++ b/src/renderer/src/components/editor/rich-markdown-key-handler.ts @@ -3,6 +3,7 @@ import type { Editor } from '@tiptap/react' import { getShortcutPlatform } from '@/lib/shortcut-platform' import { useAppStore } from '@/store' import { isMarkdownPreviewFindShortcut } from './markdown-preview-search' +import { handleRichMarkdownAddReviewNoteShortcut } from './rich-markdown-annotation-shortcut' import type { LinkBubbleState } from './RichMarkdownLinkBubble' import { commitRow, type DocLinkMenuRow, type DocLinkMenuState } from './rich-markdown-commands' import { @@ -21,6 +22,7 @@ import { handleRichMarkdownCitationKey } from './rich-markdown-citation-keyboard import type { RichMarkdownHtmlSuperscriptLinkContext } from './rich-markdown-html-superscript-link-context' import { handleRichMarkdownLinkShortcut } from './rich-markdown-link-shortcut' import { handleRichMarkdownSaveShortcut } from './rich-markdown-save-shortcut' +import { flushPendingProseMirrorSelection } from './rich-markdown-selection-flush' export type KeyHandlerContext = { isMac: boolean @@ -44,6 +46,7 @@ export type KeyHandlerContext = { typedEmptyOrderedListMarkerRef: MutableRefObject flushPendingSerialization: () => void openSearchRef: MutableRefObject<() => void> + openAnnotationPopoverRef: MutableRefObject<(requireLiveSelection?: boolean) => boolean> setIsEditingLink: (editing: boolean) => void setLinkBubble: (bubble: LinkBubbleState | null) => void setSelectedCommandIndex: Dispatch> @@ -59,47 +62,6 @@ function isComposingMarkdownInput(event: KeyboardEvent, editor: Editor | null): return event.isComposing || editor?.view.composing === true } -type NativeSelectionSnapshot = { - anchorNode: Node | null - anchorOffset: number - focusNode: Node | null - focusOffset: number -} - -type ProseMirrorDomObserver = { - currentSelection?: { - set?: (selection: NativeSelectionSnapshot) => void - } - flush?: () => void -} - -type ProseMirrorViewWithDomObserver = Editor['view'] & { - domObserver?: ProseMirrorDomObserver -} - -function flushPendingProseMirrorSelection(editor: Editor): void { - let observer: ProseMirrorDomObserver | undefined - try { - observer = (editor.view as ProseMirrorViewWithDomObserver).domObserver - } catch { - return - } - - if (typeof observer?.flush !== 'function') { - return - } - - // Why: immediate Tab after a mouse click can run before ProseMirror has - // copied the native selection into editor state, so list commands hit stale item state. - observer.currentSelection?.set?.({ - anchorNode: null, - anchorOffset: 0, - focusNode: null, - focusOffset: 0 - }) - observer.flush() -} - /** * Why: extracted from RichMarkdownEditor to stay under the file line-limit * while keeping the keyboard handler logic co-located and testable. @@ -133,6 +95,9 @@ export function createRichMarkdownKeyHandler( if (handleRichMarkdownSaveShortcut(ctx, event)) { return true } + if (handleRichMarkdownAddReviewNoteShortcut(ctx, event)) { + return true + } // Strikethrough: Cmd/Ctrl+Shift+X (standard shortcut used by Google // Docs, Notion, etc. — supplements Tiptap's built-in Mod+Shift+S). diff --git a/src/renderer/src/components/editor/rich-markdown-selection-flush.ts b/src/renderer/src/components/editor/rich-markdown-selection-flush.ts new file mode 100644 index 00000000000..a7dd336ed2e --- /dev/null +++ b/src/renderer/src/components/editor/rich-markdown-selection-flush.ts @@ -0,0 +1,42 @@ +import type { Editor } from '@tiptap/react' + +type NativeSelectionSnapshot = { + anchorNode: Node | null + anchorOffset: number + focusNode: Node | null + focusOffset: number +} + +type ProseMirrorDomObserver = { + currentSelection?: { + set?: (selection: NativeSelectionSnapshot) => void + } + flush?: () => void +} + +type ProseMirrorViewWithDomObserver = Editor['view'] & { + domObserver?: ProseMirrorDomObserver +} + +export function flushPendingProseMirrorSelection(editor: Editor): void { + let observer: ProseMirrorDomObserver | undefined + try { + observer = (editor.view as ProseMirrorViewWithDomObserver).domObserver + } catch { + return + } + + if (typeof observer?.flush !== 'function') { + return + } + + // Why: immediate Tab after a mouse click can run before ProseMirror has + // copied the native selection into editor state, so list commands hit stale item state. + observer.currentSelection?.set?.({ + anchorNode: null, + anchorOffset: 0, + focusNode: null, + focusOffset: 0 + }) + observer.flush() +} diff --git a/src/renderer/src/components/editor/rich-markdown-tab-key-handler.test.ts b/src/renderer/src/components/editor/rich-markdown-tab-key-handler.test.ts index b9675d5e385..de6b9934d27 100644 --- a/src/renderer/src/components/editor/rich-markdown-tab-key-handler.test.ts +++ b/src/renderer/src/components/editor/rich-markdown-tab-key-handler.test.ts @@ -89,6 +89,7 @@ function createContext(editor: Editor): KeyHandlerContext { subscribe: () => () => {}, update: () => {} }, + openAnnotationPopoverRef: { current: vi.fn() }, setIsEditingLink: vi.fn(), setLinkBubble: vi.fn(), setSelectedCommandIndex: vi.fn(), diff --git a/src/renderer/src/components/editor/use-rich-markdown-table-of-contents.ts b/src/renderer/src/components/editor/use-rich-markdown-table-of-contents.ts new file mode 100644 index 00000000000..de2040627cd --- /dev/null +++ b/src/renderer/src/components/editor/use-rich-markdown-table-of-contents.ts @@ -0,0 +1,44 @@ +import { useCallback, useMemo, type MutableRefObject } from 'react' +import type { MarkdownTocItem } from './markdown-table-of-contents' +import { findRichMarkdownTocHeadingTarget } from './rich-markdown-toc-heading-target' +import { selectMarkdownTableOfContents } from './markdown-toc-visibility-gate' + +function flattenMarkdownTocItems(items: MarkdownTocItem[]): MarkdownTocItem[] { + return items.flatMap((item) => [item, ...flattenMarkdownTocItems(item.children)]) +} + +export function useRichMarkdownTableOfContents( + showTableOfContents: boolean, + content: string, + scrollContainerRef: MutableRefObject +): { + tableOfContentsItems: MarkdownTocItem[] + navigateToTableOfContentsItem: (id: string) => void +} { + // Why: building the table of contents runs a full-document remark parse on + // every content change. The result is only used while the panel is open + // (closed by default), so gate the parse on visibility; including + // showTableOfContents in deps rebuilds the outline the moment it opens. + const tableOfContentsItems = useMemo( + () => selectMarkdownTableOfContents(showTableOfContents, content), + [content, showTableOfContents] + ) + const flatTableOfContentsItems = useMemo( + () => flattenMarkdownTocItems(tableOfContentsItems), + [tableOfContentsItems] + ) + + const navigateToTableOfContentsItem = useCallback( + (id: string): void => { + const container = scrollContainerRef.current + if (!container) { + return + } + const heading = findRichMarkdownTocHeadingTarget(container, flatTableOfContentsItems, id) + heading?.scrollIntoView({ block: 'center' }) + }, + [flatTableOfContentsItems, scrollContainerRef] + ) + + return { tableOfContentsItems, navigateToTableOfContentsItem } +} diff --git a/src/renderer/src/components/editor/useRichMarkdownReviewController.ts b/src/renderer/src/components/editor/useRichMarkdownReviewController.ts index 43bade891b8..82b408006b1 100644 --- a/src/renderer/src/components/editor/useRichMarkdownReviewController.ts +++ b/src/renderer/src/components/editor/useRichMarkdownReviewController.ts @@ -201,41 +201,51 @@ export function useRichMarkdownReviewController({ ] ) - const openAnnotationPopover = useCallback((): void => { - if (!annotationTarget || !canAnnotateRichMarkdown) { - return - } - const editor = editorRef.current - const root = rootRef.current - const liveTarget = editor && root ? getRichMarkdownAnnotationTarget(editor, root) : null - const target = editor - ? clampRichMarkdownAnnotationTarget(editor, liveTarget ?? annotationTarget) - : annotationTarget - if ( - !target || - hasRichMarkdownCommentForRange(markdownComments, target, markdownSourceLineOffset) - ) { + // Why: reports whether a composer actually opened so keyboard callers can + // leave the chord unconsumed when this no-ops (mouse callers ignore it). + const openAnnotationPopover = useCallback( + (requireLiveSelection = false): boolean => { + if (!canAnnotateRichMarkdown) { + return false + } + const editor = editorRef.current + const root = rootRef.current + // Why: keyboard callers require the live selection to avoid stale-target + // races; the mouse button may use the target from the render that exposed it. + const liveTarget = editor && root ? getRichMarkdownAnnotationTarget(editor, root) : null + const baseTarget = liveTarget ?? (requireLiveSelection ? null : annotationTarget) + if (!baseTarget) { + return false + } + const target = editor ? clampRichMarkdownAnnotationTarget(editor, baseTarget) : baseTarget + if ( + !target || + hasRichMarkdownCommentForRange(markdownComments, target, markdownSourceLineOffset) + ) { + setAnnotationTarget(null) + return false + } + editor?.view.dispatch( + editor.state.tr.setMeta(richMarkdownAnnotationHighlightPluginKey, { + activeRange: { from: target.from, to: target.to } + }) + ) + // Why: opening a draft should reserve the notes rail immediately; saved notes stay visible. + setReviewRailOpen(true) + setAnnotationPopover(target) setAnnotationTarget(null) - return - } - editor?.view.dispatch( - editor.state.tr.setMeta(richMarkdownAnnotationHighlightPluginKey, { - activeRange: { from: target.from, to: target.to } - }) - ) - // Why: opening a draft should reserve the notes rail immediately; saved notes stay visible. - setReviewRailOpen(true) - setAnnotationPopover(target) - setAnnotationTarget(null) - }, [ - annotationTarget, - canAnnotateRichMarkdown, - editorRef, - markdownComments, - markdownSourceLineOffset, - rootRef, - setReviewRailOpen - ]) + return true + }, + [ + annotationTarget, + canAnnotateRichMarkdown, + editorRef, + markdownComments, + markdownSourceLineOffset, + rootRef, + setReviewRailOpen + ] + ) useEffect(() => { if (canAnnotateRichMarkdown) { diff --git a/src/shared/keybindings.test.ts b/src/shared/keybindings.test.ts index 4bf8344f499..db6ef52f138 100644 --- a/src/shared/keybindings.test.ts +++ b/src/shared/keybindings.test.ts @@ -197,6 +197,16 @@ describe('keybindings', () => { expect(formatKeybindingList(['Mod+Shift+O'], 'darwin')).toBe('⌘⇧O') }) + it('defines a default shortcut for adding an editor review note', () => { + expect(getEffectiveKeybindingsForAction('editor.addReviewNote', 'darwin')).toEqual([ + 'Mod+Alt+N' + ]) + expect(getEffectiveKeybindingsForAction('editor.addReviewNote', 'linux')).toEqual(['Mod+Alt+N']) + expect(getEffectiveKeybindingsForAction('editor.addReviewNote', 'win32')).toEqual(['Mod+Alt+N']) + expect(formatKeybindingList(['Mod+Alt+N'], 'darwin')).toBe('⌘⌥N') + expect(formatKeybindingList(['Mod+Alt+N'], 'linux')).toBe('Ctrl+Alt+N') + }) + it('defines platform-native replace-in-editor shortcuts', () => { expect(getEffectiveKeybindingsForAction('editor.replace', 'darwin')).toEqual(['Mod+Alt+F']) expect(getEffectiveKeybindingsForAction('editor.replace', 'linux')).toEqual(['Mod+H']) diff --git a/src/shared/keybindings.ts b/src/shared/keybindings.ts index f3c04d2ac9e..097cb661eed 100644 --- a/src/shared/keybindings.ts +++ b/src/shared/keybindings.ts @@ -92,6 +92,7 @@ export type KeybindingActionId = | 'editor.copyContext' | 'editor.previousChange' | 'editor.nextChange' + | 'editor.addReviewNote' | 'fileExplorer.undo' | 'fileExplorer.redo' | 'fileExplorer.copyPath' @@ -852,6 +853,14 @@ export const KEYBINDING_DEFINITIONS: readonly KeybindingDefinition[] = [ defaultBindings: platformBindings(['F7']), allowBareKeybindings: true }, + { + id: 'editor.addReviewNote', + title: 'Add Review Note', + group: 'Editors', + scope: 'editor', + searchKeywords: ['shortcut', 'editor', 'markdown', 'note', 'comment', 'annotation', 'review'], + defaultBindings: platformBindings(['Mod+Alt+N']) + }, { id: 'fileExplorer.undo', title: 'Undo file operation', diff --git a/tests/e2e/markdown-add-review-note-shortcut.spec.ts b/tests/e2e/markdown-add-review-note-shortcut.spec.ts new file mode 100644 index 00000000000..3fb16f5c073 --- /dev/null +++ b/tests/e2e/markdown-add-review-note-shortcut.spec.ts @@ -0,0 +1,184 @@ +import { test, expect } from './helpers/orca-app' +import { waitForActiveWorktree, waitForSessionReady } from './helpers/store' +import { + cleanupMarkdownFixture, + createMarkdownFixture, + getActiveWorktreeContext, + openMarkdownFixture, + waitForRichMarkdownEditor +} from './helpers/markdown-ordered-list-exit' + +test.describe('Markdown add-review-note shortcut', () => { + test.beforeEach(async ({ orcaPage }) => { + await waitForSessionReady(orcaPage) + await waitForActiveWorktree(orcaPage) + }) + + test('opens the review-note composer for the current selection in the rich editor', async ({ + orcaPage + }, testInfo) => { + const context = await getActiveWorktreeContext(orcaPage) + let filePath: string | null = null + + try { + filePath = await createMarkdownFixture( + context, + 'add-review-note', + testInfo.workerIndex, + 'A paragraph to annotate with a review note.\n' + ) + await openMarkdownFixture(orcaPage, context, filePath) + const editor = await waitForRichMarkdownEditor(orcaPage) + await editor.click() + await orcaPage.keyboard.press('ControlOrMeta+A') + + await orcaPage.keyboard.press('ControlOrMeta+Alt+N') + + await expect(orcaPage.getByPlaceholder('Add note for the AI')).toBeVisible({ + timeout: 5_000 + }) + } finally { + await cleanupMarkdownFixture(filePath) + } + }) + + test('opens the composer for the current selection in the Monaco source editor', async ({ + orcaPage + }, testInfo) => { + const context = await getActiveWorktreeContext(orcaPage) + let filePath: string | null = null + + try { + filePath = await createMarkdownFixture( + context, + 'add-review-note-source', + testInfo.workerIndex, + 'A paragraph to annotate from the source editor.\n' + ) + await openMarkdownFixture(orcaPage, context, filePath) + await waitForRichMarkdownEditor(orcaPage) + await orcaPage.evaluate(() => { + // Why: switch to source mode through the store — the toolbar toggle is + // an icon menu that is brittle to locate; the store action is what it + // dispatches anyway, and the shortcut under test is mode-independent. + const store = window.__store + if (!store) { + throw new Error('window.__store is not available') + } + const state = store.getState() + if (!state.activeFileId) { + throw new Error('No active editor file') + } + state.setMarkdownViewMode(state.activeFileId, 'source') + }) + const monaco = orcaPage.locator('.monaco-editor').first() + await expect(monaco).toBeVisible({ timeout: 25_000 }) + await monaco.click() + await orcaPage.keyboard.press('ControlOrMeta+A') + + await orcaPage.keyboard.press('ControlOrMeta+Alt+N') + + await expect(orcaPage.getByPlaceholder('Add note for the AI')).toBeVisible({ + timeout: 5_000 + }) + } finally { + await cleanupMarkdownFixture(filePath) + } + }) + + test('opens the inline composer for the selected block in the markdown preview', async ({ + orcaPage + }, testInfo) => { + const context = await getActiveWorktreeContext(orcaPage) + let filePath: string | null = null + + try { + filePath = await createMarkdownFixture( + context, + 'add-review-note-preview', + testInfo.workerIndex, + 'A paragraph to annotate from the preview.\n' + ) + await openMarkdownFixture(orcaPage, context, filePath) + await waitForRichMarkdownEditor(orcaPage) + await orcaPage.evaluate(() => { + // Why: open the real markdown-preview tab through the store — preview + // is a separate file mode, not a view mode of the edit tab, and the + // toolbar entry point is an icon menu that is brittle to locate. + const store = window.__store + if (!store) { + throw new Error('window.__store is not available') + } + const state = store.getState() + const file = state.openFiles.find((f) => f.id === state.activeFileId) + if (!file) { + throw new Error('No active editor file') + } + state.openMarkdownPreview( + { + filePath: file.filePath, + relativePath: file.relativePath, + worktreeId: file.worktreeId, + runtimeEnvironmentId: file.runtimeEnvironmentId, + language: 'markdown' + }, + { sourceFileId: file.id } + ) + }) + await expect(orcaPage.locator('[data-annotation-block-key]').first()).toBeVisible({ + timeout: 25_000 + }) + + await orcaPage.evaluate(() => { + // Why: mirror a reader selecting rendered text — focus lands on the + // preview's tabIndex=0 root and the DOM selection covers the block. + const block = document.querySelector('[data-annotation-block-key]') + if (!block) { + throw new Error('No annotation block found in preview') + } + const focusable = block.closest('[tabindex]') + if (!focusable) { + throw new Error('No focusable preview root above the annotation block') + } + focusable.focus() + const paragraph = block.querySelector('p') ?? block + const selection = window.getSelection() + const range = document.createRange() + range.selectNodeContents(paragraph) + selection?.removeAllRanges() + selection?.addRange(range) + }) + + await orcaPage.keyboard.press('ControlOrMeta+Alt+N') + + await expect(orcaPage.getByPlaceholder('Add note for the AI')).toBeVisible({ + timeout: 5_000 + }) + } finally { + await cleanupMarkdownFixture(filePath) + } + }) + + test('does not open the composer without a text selection', async ({ orcaPage }, testInfo) => { + const context = await getActiveWorktreeContext(orcaPage) + let filePath: string | null = null + + try { + filePath = await createMarkdownFixture( + context, + 'add-review-note-no-selection', + testInfo.workerIndex, + 'A paragraph without any selection.\n' + ) + await openMarkdownFixture(orcaPage, context, filePath) + const editor = await waitForRichMarkdownEditor(orcaPage) + await editor.click() + + await orcaPage.keyboard.press('ControlOrMeta+Alt+N') + + await expect(orcaPage.getByPlaceholder('Add note for the AI')).toHaveCount(0) + } finally { + await cleanupMarkdownFixture(filePath) + } + }) +}) From 1284a00e93fa1a6fff38e4103dfe59a3fbcc2f1d Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 16 Jul 2026 21:29:17 -0700 Subject: [PATCH 20/24] fix(native-chat): don't force a default model/effort on agent spawn (#9134) #9085 made resolveNativeChatSessionOptionDefaults fall back to the catalog default model (sonnet) and effort (high) whenever the user had not explicitly picked one, injecting `--model sonnet --effort high` (and codex `-m`/`-c` equivalents) onto every agent spawn: composer, worktree activation, source-control agents, onboarding, work-item launch, floating terminal, and new-tab launch. That silently overrode each user's own CLI-configured default model. Return undefined (spawn the agent exactly as its own CLI would) unless the user has explicitly selected a model, and carry only explicitly stored option values. An explicit model selection still applies that model and its catalog option defaults via resolveAgentSessionOptionLaunch. --- .../onboarding-folder-agent-startup.test.ts | 10 ++-- .../src/lib/launch-agent-in-new-tab.test.ts | 25 ++++----- .../src/lib/launch-work-item-direct.test.ts | 6 +- .../worktree-activation-created-agent.test.ts | 5 +- .../repos-onboarding-folder-startup.test.ts | 5 +- .../agent-session-option-catalog.test.ts | 55 ++++++++++++++++--- .../native-chat-session-option-defaults.ts | 24 ++------ 7 files changed, 72 insertions(+), 58 deletions(-) diff --git a/src/renderer/src/components/onboarding/onboarding-folder-agent-startup.test.ts b/src/renderer/src/components/onboarding/onboarding-folder-agent-startup.test.ts index 1e57102600f..18068817358 100644 --- a/src/renderer/src/components/onboarding/onboarding-folder-agent-startup.test.ts +++ b/src/renderer/src/components/onboarding/onboarding-folder-agent-startup.test.ts @@ -14,8 +14,7 @@ describe('buildOnboardingFolderAgentStartup', () => { }) expect(startup).toEqual({ - command: - "codex '-m' 'gpt-5.6-sol' '-c' 'model_reasoning_effort=medium' '--dangerously-bypass-approvals-and-sandbox'", + command: "codex '--dangerously-bypass-approvals-and-sandbox'", env: {}, launchAgent: 'codex', launchConfig: { @@ -23,7 +22,7 @@ describe('buildOnboardingFolderAgentStartup', () => { agentArgs: '--dangerously-bypass-approvals-and-sandbox', agentEnv: {} }, - sessionOptions: { model: 'gpt-5.6-sol', effort: 'medium' }, + sessionOptions: undefined, telemetry: { agent_kind: 'codex', launch_source: 'onboarding', @@ -99,8 +98,7 @@ describe('buildOnboardingFolderAgentStartup', () => { false ) ).toEqual({ - command: - "echo onboarding-folder-agent '-m' 'gpt-5.6-sol' '-c' 'model_reasoning_effort=medium' '--dangerously-bypass-approvals-and-sandbox'", + command: "echo onboarding-folder-agent '--dangerously-bypass-approvals-and-sandbox'", env: {}, launchAgent: 'codex', launchConfig: { @@ -108,7 +106,7 @@ describe('buildOnboardingFolderAgentStartup', () => { agentArgs: '--dangerously-bypass-approvals-and-sandbox', agentEnv: {} }, - sessionOptions: { model: 'gpt-5.6-sol', effort: 'medium' }, + sessionOptions: undefined, telemetry: { agent_kind: 'codex', launch_source: 'onboarding', diff --git a/src/renderer/src/lib/launch-agent-in-new-tab.test.ts b/src/renderer/src/lib/launch-agent-in-new-tab.test.ts index b3c5029a317..c43f98ef969 100644 --- a/src/renderer/src/lib/launch-agent-in-new-tab.test.ts +++ b/src/renderer/src/lib/launch-agent-in-new-tab.test.ts @@ -340,8 +340,7 @@ describe('launchAgentInNewTab', () => { environmentId: 'web-runtime', targetGroupId: 'group-1', activate: true, - command: - "codex '-m' 'gpt-5.6-sol' '-c' 'model_reasoning_effort=medium' '--model' 'gpt-5' '--reasoning-effort' 'high' 'fix the spinner'", + command: "codex '--model' 'gpt-5' '--reasoning-effort' 'high' 'fix the spinner'", env: { CODEX_PROFILE: 'captured' }, startupCommandDelivery: 'shell-ready', launchConfig: { @@ -486,8 +485,7 @@ describe('launchAgentInNewTab', () => { expect(mockQueueTabStartupCommand).toHaveBeenCalledWith( 'tab-1', expect.objectContaining({ - command: - "claude '--model' 'sonnet' '--effort' 'high' '--dangerously-skip-permissions' --prefill 'review Bob''s change'" + command: "claude '--dangerously-skip-permissions' --prefill 'review Bob''s change'" }) ) }) @@ -505,7 +503,7 @@ describe('launchAgentInNewTab', () => { expect(mockQueueTabStartupCommand).toHaveBeenCalledWith( 'tab-1', expect.objectContaining({ - command: 'claude "--model" "sonnet" "--effort" "high" "--dangerously-skip-permissions"' + command: 'claude "--dangerously-skip-permissions"' }) ) }) @@ -523,7 +521,7 @@ describe('launchAgentInNewTab', () => { expect(mockQueueTabStartupCommand).toHaveBeenCalledWith( 'tab-1', expect.objectContaining({ - command: "claude '--model' 'sonnet' '--effort' 'high' '--dangerously-skip-permissions'" + command: "claude '--dangerously-skip-permissions'" }) ) }) @@ -543,8 +541,7 @@ describe('launchAgentInNewTab', () => { expect(mockQueueTabStartupCommand).toHaveBeenCalledWith( 'tab-1', expect.objectContaining({ - command: - 'codex "-m" "gpt-5.6-sol" "-c" "model_reasoning_effort=medium" "--model" "gpt-5" "fix the spinner"' + command: 'codex "--model" "gpt-5" "fix the spinner"' }) ) }) @@ -564,8 +561,7 @@ describe('launchAgentInNewTab', () => { expect(mockQueueTabStartupCommand).toHaveBeenCalledWith( 'tab-1', expect.objectContaining({ - command: - "claude '--model' 'sonnet' '--effort' 'high' '--dangerously-skip-permissions' --prefill 'review Bob'\\''s change'" + command: "claude '--dangerously-skip-permissions' --prefill 'review Bob'\\''s change'" }) ) }) @@ -583,7 +579,7 @@ describe('launchAgentInNewTab', () => { expect(mockQueueTabStartupCommand).toHaveBeenCalledWith( 'tab-1', expect.objectContaining({ - command: "claude '--model' 'sonnet' '--effort' 'high' '--dangerously-skip-permissions'" + command: "claude '--dangerously-skip-permissions'" }) ) }) @@ -620,8 +616,7 @@ describe('launchAgentInNewTab', () => { expect(mockQueueTabStartupCommand).toHaveBeenCalledWith( 'tab-1', expect.objectContaining({ - command: - "claude '--model' 'sonnet' '--effort' 'high' '--dangerously-skip-permissions' --prefill 'review Bob'\\''s change'" + command: "claude '--dangerously-skip-permissions' --prefill 'review Bob'\\''s change'" }) ) }) @@ -642,7 +637,7 @@ describe('launchAgentInNewTab', () => { expect(mockQueueTabStartupCommand).toHaveBeenCalledWith( 'tab-1', expect.objectContaining({ - command: "claude '--model' 'sonnet' '--effort' 'high' '--dangerously-skip-permissions'" + command: "claude '--dangerously-skip-permissions'" }) ) expect(mockPasteDraftWhenAgentReady).toHaveBeenCalledWith( @@ -848,7 +843,7 @@ describe('launchAgentInNewTab', () => { expect(mockQueueTabStartupCommand).toHaveBeenCalledWith( 'tab-1', expect.objectContaining({ - command: "codex '-m' 'gpt-5.6-sol' '-c' 'model_reasoning_effort=medium' '--model' 'gpt-5.5'" + command: "codex '--model' 'gpt-5.5'" }) ) }) diff --git a/src/renderer/src/lib/launch-work-item-direct.test.ts b/src/renderer/src/lib/launch-work-item-direct.test.ts index 9eedb05be75..c76c91bc030 100644 --- a/src/renderer/src/lib/launch-work-item-direct.test.ts +++ b/src/renderer/src/lib/launch-work-item-direct.test.ts @@ -402,7 +402,7 @@ describe('launchWorkItemDirect', () => { cmdOverrides: {}, agentArgs: '--dangerously-skip-permissions', agentEnv: {}, - sessionOptions: { model: 'sonnet', effort: 'high' }, + sessionOptions: undefined, platform: 'win32', isRemote: false }) @@ -526,7 +526,7 @@ describe('launchWorkItemDirect', () => { cmdOverrides: {}, agentArgs: '--yolo', agentEnv: {}, - sessionOptions: { model: 'auto' }, + sessionOptions: undefined, platform: 'linux', isRemote: true }) @@ -536,7 +536,7 @@ describe('launchWorkItemDirect', () => { cmdOverrides: {}, agentArgs: '--yolo', agentEnv: {}, - sessionOptions: { model: 'auto' }, + sessionOptions: undefined, platform: 'linux', isRemote: true, allowEmptyPromptLaunch: true diff --git a/src/renderer/src/lib/worktree-activation-created-agent.test.ts b/src/renderer/src/lib/worktree-activation-created-agent.test.ts index 89fcb83c92d..dfa23851a6f 100644 --- a/src/renderer/src/lib/worktree-activation-created-agent.test.ts +++ b/src/renderer/src/lib/worktree-activation-created-agent.test.ts @@ -96,8 +96,7 @@ describe('activateAndRevealWorktree created agent reopen', () => { expect(result).toEqual({ primaryTabId: reopenedTab?.id }) expect(reopenedTab).toBeDefined() expect(state.pendingStartupByTabId[reopenedTab!.id]).toEqual({ - command: - "codex '-m' 'gpt-5.6-sol' '-c' 'model_reasoning_effort=medium' '--dangerously-bypass-approvals-and-sandbox'", + command: "codex '--dangerously-bypass-approvals-and-sandbox'", env: {}, launchAgent: 'codex', launchConfig: { @@ -106,7 +105,7 @@ describe('activateAndRevealWorktree created agent reopen', () => { agentEnv: {} }, launchToken: expect.any(String), - sessionOptions: { model: 'gpt-5.6-sol', effort: 'medium' }, + sessionOptions: undefined, telemetry: { agent_kind: 'codex', launch_source: 'sidebar', diff --git a/src/renderer/src/store/slices/repos-onboarding-folder-startup.test.ts b/src/renderer/src/store/slices/repos-onboarding-folder-startup.test.ts index b5c44f9ca40..18f6cfb1f5e 100644 --- a/src/renderer/src/store/slices/repos-onboarding-folder-startup.test.ts +++ b/src/renderer/src/store/slices/repos-onboarding-folder-startup.test.ts @@ -58,8 +58,7 @@ describe('repo slice skipped-onboarding folder startup', () => { { sidebarRevealBehavior: 'auto', startup: { - command: - "codex '-m' 'gpt-5.6-sol' '-c' 'model_reasoning_effort=medium' '--dangerously-bypass-approvals-and-sandbox'", + command: "codex '--dangerously-bypass-approvals-and-sandbox'", env: {}, launchAgent: 'codex', launchConfig: { @@ -67,7 +66,7 @@ describe('repo slice skipped-onboarding folder startup', () => { agentArgs: '--dangerously-bypass-approvals-and-sandbox', agentEnv: {} }, - sessionOptions: { model: 'gpt-5.6-sol', effort: 'medium' }, + sessionOptions: undefined, telemetry: { agent_kind: 'codex', launch_source: 'onboarding', diff --git a/src/shared/agent-session-option-catalog.test.ts b/src/shared/agent-session-option-catalog.test.ts index c4e7eca1404..ffabb087e61 100644 --- a/src/shared/agent-session-option-catalog.test.ts +++ b/src/shared/agent-session-option-catalog.test.ts @@ -72,7 +72,7 @@ describe('agent session option catalog', () => { }) }) - it('restores defaults per model without leaking values across models', () => { + it('resolves only stored values without leaking values across models', () => { let persisted = updateNativeChatSessionOptionDefaults({ persisted: undefined, agent: 'claude', @@ -96,17 +96,56 @@ describe('agent session option catalog', () => { }) expect(resolveNativeChatSessionOptionDefaults(persisted, 'claude')).toEqual({ - model: 'sonnet', - effort: 'high' + model: 'sonnet' }) expect(persisted.claude?.valuesByModel?.opus).toEqual({ effort: 'xhigh' }) }) - it('seeds a fresh launch from the catalog defaults', () => { - expect(resolveNativeChatSessionOptionDefaults(undefined, 'claude')).toEqual({ - model: 'sonnet', - effort: 'high' - }) + it('spawns vanilla when the user has not explicitly selected a model', () => { + // Regression (#9085): a fresh launch must not force the catalog default + // model/effort — the agent must spawn exactly as its own CLI would. + expect(resolveNativeChatSessionOptionDefaults(undefined, 'claude')).toBeUndefined() + expect(resolveNativeChatSessionOptionDefaults({}, 'claude')).toBeUndefined() expect(resolveNativeChatSessionOptionDefaults({}, 'future-agent')).toBeUndefined() }) + + it('resolves an explicitly selected model and only its stored options', () => { + let persisted = updateNativeChatSessionOptionDefaults({ + persisted: undefined, + agent: 'claude', + modelId: 'opus', + optionId: 'model', + value: 'opus' + }) + expect(resolveNativeChatSessionOptionDefaults(persisted, 'claude')).toEqual({ + model: 'opus' + }) + persisted = updateNativeChatSessionOptionDefaults({ + persisted, + agent: 'claude', + modelId: 'opus', + optionId: 'effort', + value: 'xhigh' + }) + expect(resolveNativeChatSessionOptionDefaults(persisted, 'claude')).toEqual({ + model: 'opus', + effort: 'xhigh' + }) + }) + + it('keeps catalog option defaults after the user explicitly selects a model', () => { + const persisted = updateNativeChatSessionOptionDefaults({ + persisted: undefined, + agent: 'claude', + modelId: 'sonnet', + optionId: 'model', + value: 'sonnet' + }) + const defaults = resolveNativeChatSessionOptionDefaults(persisted, 'claude') + + expect(resolveAgentSessionOptionLaunch('claude', defaults)).toEqual({ + args: ['--model', 'sonnet', '--effort', 'high'], + appliedValues: { model: 'sonnet', effort: 'high' } + }) + }) }) diff --git a/src/shared/native-chat-session-option-defaults.ts b/src/shared/native-chat-session-option-defaults.ts index 98bd0187113..5edc3c49193 100644 --- a/src/shared/native-chat-session-option-defaults.ts +++ b/src/shared/native-chat-session-option-defaults.ts @@ -1,10 +1,5 @@ import type { AgentType } from './agent-status-types' -import { - catalogDefaultModel, - findCatalogModel, - getAgentSessionOptionCatalog, - sessionOptionValueIsValid -} from './agent-session-option-catalog' +import { sessionOptionValueIsValid } from './agent-session-option-catalog' import type { PersistedNativeChatSessionOptions, SessionOptionValue @@ -14,16 +9,10 @@ export function resolveNativeChatSessionOptionDefaults( persisted: PersistedNativeChatSessionOptions | null | undefined, agent: AgentType ): Record | undefined { - const catalog = getAgentSessionOptionCatalog(agent) const entry = persisted?.[agent] - // Why: fresh launches must be authoritative too, so resolve the catalog's - // declared fallback before the launch command is assembled. - const modelId = - typeof entry?.model === 'string' && entry.model.trim() - ? entry.model - : catalog - ? catalogDefaultModel(catalog)?.id - : undefined + // Why: untouched settings must preserve the agent CLI's configured defaults; + // only a model explicitly selected by the user authorizes launch flags. + const modelId = typeof entry?.model === 'string' && entry.model.trim() ? entry.model : undefined if (!modelId) { return undefined } @@ -36,11 +25,6 @@ export function resolveNativeChatSessionOptionDefaults( } } } - - const model = catalog ? findCatalogModel(catalog, modelId) : undefined - for (const option of model?.options ?? []) { - values[option.id] ??= option.kind.defaultValue - } return values } From fa85536f3a952430375b8448880d32cd82397005 Mon Sep 17 00:00:00 2001 From: Glen Maddern Date: Fri, 17 Jul 2026 14:32:13 +1000 Subject: [PATCH 21/24] fix(ssh): repair unbuilt relay native deps (#8686) Co-authored-by: Orca Co-authored-by: Neil <4138956+nwparker@users.noreply.github.com> Co-authored-by: Jinwoo-H Co-authored-by: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> --- src/main/ssh/ssh-connection.test.ts | 151 +++- src/main/ssh/ssh-connection.ts | 255 ++++-- src/main/ssh/ssh-file-transfer-abort.test.ts | 55 ++ src/main/ssh/ssh-file-transfer-abort.ts | 76 ++ src/main/ssh/ssh-relay-build-toolchain.ts | 9 +- .../ssh-relay-cross-version-isolation.test.ts | 7 +- src/main/ssh/ssh-relay-deploy-helpers.test.ts | 147 +++- src/main/ssh/ssh-relay-deploy-helpers.ts | 158 +--- src/main/ssh/ssh-relay-deploy-timing.ts | 13 + src/main/ssh/ssh-relay-deploy.test.ts | 210 ++++- src/main/ssh/ssh-relay-deploy.ts | 818 ++++++++++++++---- src/main/ssh/ssh-relay-exec-command.ts | 156 ++++ src/main/ssh/ssh-relay-gc-claim.ts | 236 +++++ src/main/ssh/ssh-relay-gc-retry.test.ts | 275 ++++++ src/main/ssh/ssh-relay-gc-tombstone.ts | 33 + .../ssh/ssh-relay-install-lock-commands.ts | 176 ++++ src/main/ssh/ssh-relay-install-lock.ts | 162 ++++ .../ssh/ssh-relay-native-deps-install.test.ts | 399 ++++++++- src/main/ssh/ssh-relay-repair-lock.ts | 153 ++++ .../ssh/ssh-relay-versioned-install.test.ts | 632 +++++++++++++- src/main/ssh/ssh-relay-versioned-install.ts | 209 ++--- src/main/ssh/ssh-remote-commands.test.ts | 335 ++++++- src/main/ssh/ssh-remote-commands.ts | 54 +- src/main/ssh/ssh-remote-node-resolution.ts | 5 + src/main/ssh/ssh-remote-platform-detection.ts | 34 +- src/main/ssh/ssh-system-fallback.test.ts | 44 +- src/main/ssh/system-ssh-command.ts | 3 + .../ssh/system-ssh-operation-lifecycle.ts | 8 +- src/relay/pty-handler.test.ts | 23 + src/relay/pty-handler.ts | 111 ++- src/relay/subprocess-test-utils.ts | 2 +- src/relay/subprocess.test.ts | 83 +- 32 files changed, 4339 insertions(+), 693 deletions(-) create mode 100644 src/main/ssh/ssh-file-transfer-abort.test.ts create mode 100644 src/main/ssh/ssh-file-transfer-abort.ts create mode 100644 src/main/ssh/ssh-relay-deploy-timing.ts create mode 100644 src/main/ssh/ssh-relay-exec-command.ts create mode 100644 src/main/ssh/ssh-relay-gc-claim.ts create mode 100644 src/main/ssh/ssh-relay-gc-retry.test.ts create mode 100644 src/main/ssh/ssh-relay-gc-tombstone.ts create mode 100644 src/main/ssh/ssh-relay-install-lock-commands.ts create mode 100644 src/main/ssh/ssh-relay-install-lock.ts create mode 100644 src/main/ssh/ssh-relay-repair-lock.ts diff --git a/src/main/ssh/ssh-connection.test.ts b/src/main/ssh/ssh-connection.test.ts index e8c5d71234c..3274d3f5daa 100644 --- a/src/main/ssh/ssh-connection.test.ts +++ b/src/main/ssh/ssh-connection.test.ts @@ -23,6 +23,7 @@ type MockSshClient = { lastExecCommand?: string lastConnectConfig?: unknown exec: (cmd: string, cb: (err: Error | undefined, channel: unknown) => void) => void + sftp: (cb: (err: Error | undefined, channel: unknown) => void) => void } let clientInstances: MockSshClient[] = [] @@ -690,15 +691,15 @@ describe('SshConnection', () => { vi.useFakeTimers() try { - const outcomePromise = conn - .exec('printf ready') - .then(() => 'opened') - .catch((error: Error) => error.message) + const outcomePromise = conn.exec('printf ready').catch((error: Error) => error) await vi.advanceTimersByTimeAsync(30_000) const outcome = await Promise.race([outcomePromise, Promise.resolve('pending')]) - expect(outcome).toBe('SSH exec channel timed out') + expect(outcome).toMatchObject({ + message: 'SSH exec channel timed out', + sshChannelCloseConfirmed: false + }) } finally { vi.useRealTimers() } @@ -784,15 +785,54 @@ describe('SshConnection', () => { try { const outcomePromise = conn .exec('printf ready', { signal: controller.signal }) - .then(() => 'opened') - .catch((error: Error) => error.name) + .catch((error: Error) => error) controller.abort() // Why: a hung socket must not pin the aborted caller for the full 30s // connect timeout — the abort settles at the 5s grace bound instead. await vi.advanceTimersByTimeAsync(5_000) - await expect(outcomePromise).resolves.toBe('AbortError') + await expect(outcomePromise).resolves.toMatchObject({ + name: 'AbortError', + sshChannelCloseConfirmed: false + }) + } finally { + vi.useRealTimers() + } + }) + + it('drains an exec channel that opens after the abort grace has settled', async () => { + const conn = new SshConnection(createTarget(), createCallbacks()) + await conn.connect() + execBehavior = 'pending' + const controller = new AbortController() + const lateChannel = Object.assign(new EventEmitter(), { + close: vi.fn(), + resume: vi.fn(), + stderr: { resume: vi.fn() } + }) + + vi.useFakeTimers() + try { + const outcomePromise = conn + .exec('printf ready', { signal: controller.signal }) + .catch((error: Error) => error) + + controller.abort() + await vi.advanceTimersByTimeAsync(5_000) + const outcome = await outcomePromise + expect(outcome).toMatchObject({ + name: 'AbortError', + sshChannelCloseConfirmed: false + }) + + pendingExecCallback?.(undefined, lateChannel) + + expect(lateChannel.resume).toHaveBeenCalledTimes(1) + expect(lateChannel.stderr.resume).toHaveBeenCalledTimes(1) + expect(lateChannel.close).toHaveBeenCalledTimes(1) + lateChannel.emit('close') + expect(outcome).toMatchObject({ sshChannelCloseConfirmed: true }) } finally { vi.useRealTimers() } @@ -837,7 +877,7 @@ describe('SshConnection', () => { const lateChannel = Object.assign(new EventEmitter(), { close: vi.fn(), resume: vi.fn(), - stderr: { resume: vi.fn() } + stderr: Object.assign(new EventEmitter(), { resume: vi.fn() }) }) const outcomePromise = conn @@ -855,11 +895,41 @@ describe('SshConnection', () => { expect(early).toBe('pending') expect(lateChannel.close).toHaveBeenCalledTimes(1) expect(lateChannel.resume).toHaveBeenCalled() + expect(() => lateChannel.emit('error', new Error('late channel teardown'))).not.toThrow() + expect(() => lateChannel.stderr.emit('error', new Error('late stderr teardown'))).not.toThrow() lateChannel.emit('close') await expect(outcomePromise).resolves.toBe('AbortError') }) + it('removes the late-channel close listener when abort grace expires', async () => { + const conn = new SshConnection(createTarget(), createCallbacks()) + await conn.connect() + vi.useFakeTimers() + try { + sftpBehavior = 'pending' + const controller = new AbortController() + const lateSftp = Object.assign(new EventEmitter(), { end: vi.fn() }) + + const outcomePromise = conn + .sftp(controller.signal) + .then(() => 'opened') + .catch((error: Error) => error.name) + await vi.advanceTimersByTimeAsync(0) + controller.abort() + pendingSftpCallback?.(undefined, lateSftp) + expect(lateSftp.listenerCount('close')).toBe(1) + expect(() => lateSftp.emit('error', new Error('late SFTP teardown'))).not.toThrow() + + await vi.advanceTimersByTimeAsync(5_000) + + await expect(outcomePromise).resolves.toBe('AbortError') + expect(lateSftp.listenerCount('close')).toBe(0) + } finally { + vi.useRealTimers() + } + }) + it('times out when ssh2 never opens an SFTP channel', async () => { const conn = new SshConnection(createTarget(), createCallbacks()) await conn.connect() @@ -867,15 +937,13 @@ describe('SshConnection', () => { vi.useFakeTimers() try { - const outcomePromise = conn - .sftp() - .then(() => 'opened') - .catch((error: Error) => error.message) + const outcomePromise = conn.sftp().catch((error: Error) => error) await vi.advanceTimersByTimeAsync(30_000) const outcome = await Promise.race([outcomePromise, Promise.resolve('pending')]) - expect(outcome).toBe('SSH SFTP channel timed out') + expect(outcome).toMatchObject({ message: 'SSH SFTP channel timed out' }) + expect(outcome).not.toHaveProperty('sshChannelCloseConfirmed') } finally { vi.useRealTimers() } @@ -1368,6 +1436,61 @@ describe('SshConnection', () => { ) }) + it('composes a caller abort into system SSH relay uploads', async () => { + vi.mocked(resolveWithSshG).mockResolvedValueOnce(createResolvedConfig()) + const conn = new SshConnection(createTarget({ configHost: 'fdpass-host' }), createCallbacks()) + const controller = new AbortController() + let transferSignal: AbortSignal | undefined + vi.mocked(uploadDirectoryViaSystemSsh).mockImplementationOnce( + (_target, _localDir, _remoteDir, options) => { + transferSignal = options?.signal + return new Promise((_resolve, reject) => { + transferSignal?.addEventListener('abort', () => reject(transferSignal?.reason), { + once: true + }) + }) + } + ) + + await conn.connect() + const upload = conn.uploadDirectory('/tmp/local-relay', '/remote/relay', { + signal: controller.signal + }) + await vi.waitFor(() => expect(transferSignal).toBeDefined()) + controller.abort() + + await expect(upload).rejects.toMatchObject({ name: 'AbortError' }) + expect(transferSignal?.aborted).toBe(true) + }) + + it('keeps connection disconnect cancellation linked to caller-scoped relay writes', async () => { + vi.mocked(resolveWithSshG).mockResolvedValueOnce(createResolvedConfig()) + const conn = new SshConnection(createTarget({ configHost: 'fdpass-host' }), createCallbacks()) + const controller = new AbortController() + let transferSignal: AbortSignal | undefined + vi.mocked(writeFileViaSystemSsh).mockImplementationOnce( + (_target, _remotePath, _contents, options) => { + transferSignal = options?.signal + return new Promise((_resolve, reject) => { + transferSignal?.addEventListener('abort', () => reject(transferSignal?.reason), { + once: true + }) + }) + } + ) + + await conn.connect() + const write = conn.writeFile('/remote/relay/.version', '0.1.0', { + signal: controller.signal + }) + await vi.waitFor(() => expect(transferSignal).toBeDefined()) + await conn.disconnect() + + await expect(write).rejects.toMatchObject({ name: 'AbortError' }) + expect(controller.signal.aborted).toBe(false) + expect(transferSignal?.aborted).toBe(true) + }) + it('keeps an upload session cancelled after the connection disconnects', async () => { const conn = new SshConnection( createTarget({ proxyCommand: 'ssh -W %h:%p bastion.example.com' }), diff --git a/src/main/ssh/ssh-connection.ts b/src/main/ssh/ssh-connection.ts index 2856d33b2f0..b8bd0cbd57f 100644 --- a/src/main/ssh/ssh-connection.ts +++ b/src/main/ssh/ssh-connection.ts @@ -41,6 +41,10 @@ import { import type { RemoteHostPlatform } from './ssh-remote-platform' import type { FileUploadSession } from '../providers/types' import { isSshSessionLimitError } from './ssh-session-limit-error' +import { + createLinkedSshFileTransferSignal, + raceSftpFileTransferWithAbort +} from './ssh-file-transfer-abort' export type { SshConnectionCallbacks } from './ssh-connection-utils' type SshRemoteFileOptions = { @@ -157,13 +161,14 @@ export class SshConnection { 'SSH exec channel timed out', (callback) => client.exec(remoteCommand, callback), (channel) => channel.close(), - options?.signal + options?.signal, + true ), options?.signal ) } - async sftp(): Promise { + async sftp(signal?: AbortSignal): Promise { if (this.useSystemSshTransport) { throw new Error('SFTP is not available when using system SSH transport') } @@ -171,12 +176,15 @@ export class SshConnection { throw new Error('Not connected') } const client = this.client - return this.openSessionChannelWithRetry(() => - this.waitForSshCallback( - 'SSH SFTP channel timed out', - (callback) => client.sftp(callback), - (sftp) => sftp.end() - ) + return this.openSessionChannelWithRetry( + () => + this.waitForSshCallback( + 'SSH SFTP channel timed out', + (callback) => client.sftp(callback), + (sftp) => sftp.end(), + signal + ), + signal ) } @@ -219,10 +227,20 @@ export class SshConnection { timeoutMessage: string, register: (callback: (error: Error | undefined, value: T) => void) => void, cleanupLateValue?: (value: T) => void, - signal?: AbortSignal + signal?: AbortSignal, + trackRemoteCommandTermination = false ): Promise { return new Promise((resolve, reject) => { + type ChannelOpenTerminationError = Error & { sshChannelCloseConfirmed: boolean } let settled = false + let unconfirmedOpenError: ChannelOpenTerminationError | null = null + const markOpenUnconfirmed = (error: Error): Error => { + if (!trackRemoteCommandTermination) { + return error + } + unconfirmedOpenError = Object.assign(error, { sshChannelCloseConfirmed: false }) + return unconfirmedOpenError + } // Why: rejecting the instant the signal aborts lets the caller proceed // while the in-flight channel open completes in the background and holds // a server-side session slot (MaxSessions). Mark the abort and settle @@ -241,16 +259,41 @@ export class SshConnection { abortDeadlineTimer = setTimeout(() => { settled = true cleanup() - reject(createSshOperationAbortError()) + reject(markOpenUnconfirmed(createSshOperationAbortError())) }, ABORTED_CHANNEL_CLOSE_GRACE_MS) } const timer = setTimeout(() => { settled = true cleanup() - reject(abortRequested ? createSshOperationAbortError() : new Error(timeoutMessage)) + reject( + markOpenUnconfirmed( + abortRequested ? createSshOperationAbortError() : new Error(timeoutMessage) + ) + ) }, CONNECT_TIMEOUT_MS) + const discardLateValue = (value: T, onClose?: () => void): void => { + const emitter = value as Partial & { + resume?: () => void + stderr?: Partial & { resume?: () => void } + } + const swallowLateError = (): void => {} + emitter.on?.('error', swallowLateError) + emitter.stderr?.on?.('error', swallowLateError) + if (onClose) { + emitter.once?.('close', onClose) + } + // Why: ssh2 can withhold CHANNEL_CLOSE while discarded exec streams + // remain unread, and teardown errors have no other owner. + emitter.resume?.() + emitter.stderr?.resume?.() + try { + cleanupLateValue?.(value) + } catch { + /* best effort */ + } + } const rejectAfterClose = (value: T): void => { - const abortError = createSshOperationAbortError() + const abortError = markOpenUnconfirmed(createSshOperationAbortError()) const emitter = value as Partial & { resume?: () => void stderr?: { resume?: () => void } @@ -262,23 +305,24 @@ export class SshConnection { } finished = true clearTimeout(closeGraceTimer) + emitter.removeListener?.('close', confirmAndDone) reject(abortError) } + const confirmAndDone = (): void => { + if (unconfirmedOpenError === abortError) { + unconfirmedOpenError.sshChannelCloseConfirmed = true + } + done() + } // Why: bounded — a remote that never confirms the close must not hang // the aborted operation forever. const closeGraceTimer = setTimeout(done, ABORTED_CHANNEL_CLOSE_GRACE_MS) if (typeof emitter.once === 'function') { - emitter.once('close', done) + emitter.once('close', confirmAndDone) } // Why: ssh2 withholds the 'close' event until the channel's streams // are drained; nobody else will ever read this discarded channel. - emitter.resume?.() - emitter.stderr?.resume?.() - try { - cleanupLateValue?.(value) - } catch { - /* best effort */ - } + discardLateValue(value) if (typeof emitter.once !== 'function') { done() } @@ -289,11 +333,11 @@ export class SshConnection { // rejected. Close that late resource so the remote channel is not // left open with no owner. if (!error && value !== undefined) { - try { - cleanupLateValue?.(value) - } catch { - /* best effort */ - } + discardLateValue(value, () => { + if (unconfirmedOpenError) { + unconfirmedOpenError.sshChannelCloseConfirmed = true + } + }) } return } @@ -334,23 +378,51 @@ export class SshConnection { async uploadDirectory( localDir: string, remoteDir: string, - options?: SshRemoteFileOptions + options?: SshRemoteFileOptions & { signal?: AbortSignal } ): Promise { - if (!this.useSystemSshTransport) { - const sftp = await this.sftp() - try { - const { uploadDirectory } = await import('./ssh-relay-deploy-helpers') - await uploadDirectory(sftp, localDir, remoteDir) - } finally { - sftp.end() + // Why: relay deployment timeout and connection teardown are independent; + // either owner must stop a transfer that can otherwise outlive its lock. + const linkedSignal = createLinkedSshFileTransferSignal( + [this.systemOperationAbortController.signal, options?.signal].filter( + (signal): signal is AbortSignal => signal !== undefined + ) + ) + try { + if (!this.useSystemSshTransport) { + const sftp = await this.sftp(linkedSignal.signal) + const swallowLateSftpError = (): void => {} + let sftpEndRequested = false + const endSftp = (): void => { + if (!sftpEndRequested) { + sftpEndRequested = true + sftp.end() + } + } + sftp.on('error', swallowLateSftpError) + sftp.once('close', () => sftp.removeListener('error', swallowLateSftpError)) + try { + const { uploadDirectory } = await import('./ssh-relay-deploy-helpers') + await raceSftpFileTransferWithAbort( + uploadDirectory(sftp, localDir, remoteDir), + linkedSignal.signal, + (onClose) => { + sftp.once('close', onClose) + endSftp() + } + ) + } finally { + endSftp() + } + return } - return + await uploadDirectoryViaSystemSsh(this.target, localDir, remoteDir, { + signal: linkedSignal.signal, + hostPlatform: options?.hostPlatform, + ...this.getSystemSshBuildArgsOptions() + }) + } finally { + linkedSignal.dispose() } - await uploadDirectoryViaSystemSsh(this.target, localDir, remoteDir, { - signal: this.systemOperationAbortController.signal, - hostPlatform: options?.hostPlatform, - ...this.getSystemSshBuildArgsOptions() - }) } async downloadFile( @@ -404,55 +476,74 @@ export class SshConnection { async writeFile( remotePath: string, contents: string, - options?: SshRemoteFileOptions + options?: SshRemoteFileOptions & { signal?: AbortSignal } ): Promise { - if (!this.useSystemSshTransport) { - const sftp = await this.sftp() - const swallowLateSftpError = (): void => {} - sftp.on('error', swallowLateSftpError) - try { - await new Promise((resolve, reject) => { - const ws = sftp.createWriteStream(remotePath) - let settled = false - const cleanup = (): void => { - ws.removeListener('close', onClose) - ws.removeListener('error', onError) + // Keep package/version writes under the same dual cancellation contract as uploads. + const linkedSignal = createLinkedSshFileTransferSignal( + [this.systemOperationAbortController.signal, options?.signal].filter( + (signal): signal is AbortSignal => signal !== undefined + ) + ) + try { + if (!this.useSystemSshTransport) { + const sftp = await this.sftp(linkedSignal.signal) + const swallowLateSftpError = (): void => {} + let sftpEndRequested = false + const endSftp = (): void => { + if (!sftpEndRequested) { + sftpEndRequested = true + sftp.end() } - const onClose = (): void => { - if (settled) { - return + } + sftp.on('error', swallowLateSftpError) + sftp.once('close', () => sftp.removeListener('error', swallowLateSftpError)) + try { + const write = new Promise((resolve, reject) => { + const ws = sftp.createWriteStream(remotePath) + let settled = false + const cleanup = (): void => { + sftp.removeListener('error', onError) + ws.removeListener('close', onClose) + ws.removeListener('error', onError) } - settled = true - cleanup() - resolve() - } - const onError = (err: Error): void => { - sftp.removeListener('error', onError) - if (settled) { - return + const onClose = (): void => { + if (settled) { + return + } + settled = true + cleanup() + resolve() } - settled = true - cleanup() - reject(err) - } - sftp.prependOnceListener('error', onError) - ws.once('close', onClose) - ws.once('error', onError) - ws.end(contents) - }) - } finally { - sftp.end() - setImmediate(() => { - sftp.removeListener('error', swallowLateSftpError) - }) + const onError = (err: Error): void => { + if (settled) { + return + } + settled = true + cleanup() + reject(err) + } + sftp.prependOnceListener('error', onError) + ws.once('close', onClose) + ws.once('error', onError) + ws.end(contents) + }) + await raceSftpFileTransferWithAbort(write, linkedSignal.signal, (onClose) => { + sftp.once('close', onClose) + endSftp() + }) + } finally { + endSftp() + } + return } - return + await writeFileViaSystemSsh(this.target, remotePath, contents, { + signal: linkedSignal.signal, + hostPlatform: options?.hostPlatform, + ...this.getSystemSshBuildArgsOptions() + }) + } finally { + linkedSignal.dispose() } - await writeFileViaSystemSsh(this.target, remotePath, contents, { - signal: this.systemOperationAbortController.signal, - hostPlatform: options?.hostPlatform, - ...this.getSystemSshBuildArgsOptions() - }) } async writeBuffer( diff --git a/src/main/ssh/ssh-file-transfer-abort.test.ts b/src/main/ssh/ssh-file-transfer-abort.test.ts new file mode 100644 index 00000000000..49abe0a1839 --- /dev/null +++ b/src/main/ssh/ssh-file-transfer-abort.test.ts @@ -0,0 +1,55 @@ +import { describe, expect, it, vi } from 'vitest' +import { raceSftpFileTransferWithAbort } from './ssh-file-transfer-abort' + +describe('raceSftpFileTransferWithAbort', () => { + it('waits for confirmed SFTP close before rejecting an abort', async () => { + const controller = new AbortController() + let confirmClose: () => void = () => {} + const promise = raceSftpFileTransferWithAbort( + new Promise(() => {}), + controller.signal, + (onClose) => { + confirmClose = onClose + } + ) + + controller.abort() + const pending = await Promise.race([ + promise.then( + () => 'settled', + () => 'settled' + ), + Promise.resolve('pending') + ]) + expect(pending).toBe('pending') + + confirmClose() + await expect(promise).rejects.toMatchObject({ + name: 'AbortError', + sshChannelCloseConfirmed: true + }) + }) + + it('marks teardown unconfirmed when SFTP never closes', async () => { + vi.useFakeTimers() + try { + const controller = new AbortController() + const promise = raceSftpFileTransferWithAbort( + new Promise(() => {}), + controller.signal, + () => {} + ) + const outcome = promise.catch((error: Error) => error) + + controller.abort() + await vi.advanceTimersByTimeAsync(5_000) + + await expect(outcome).resolves.toMatchObject({ + name: 'AbortError', + sshChannelCloseConfirmed: false + }) + } finally { + vi.useRealTimers() + } + }) +}) diff --git a/src/main/ssh/ssh-file-transfer-abort.ts b/src/main/ssh/ssh-file-transfer-abort.ts new file mode 100644 index 00000000000..a7b6cce2e49 --- /dev/null +++ b/src/main/ssh/ssh-file-transfer-abort.ts @@ -0,0 +1,76 @@ +import { createSshOperationAbortError } from './ssh-connection-utils' + +export function createLinkedSshFileTransferSignal(signals: readonly AbortSignal[]): { + signal: AbortSignal + dispose: () => void +} { + const controller = new AbortController() + const listeners = signals.map((signal) => { + const listener = (): void => controller.abort(signal.reason) + if (signal.aborted) { + listener() + } else { + signal.addEventListener('abort', listener, { once: true }) + } + return { signal, listener } + }) + return { + signal: controller.signal, + dispose: () => { + for (const { signal, listener } of listeners) { + signal.removeEventListener('abort', listener) + } + } + } +} + +export function raceSftpFileTransferWithAbort( + operation: Promise, + signal: AbortSignal, + closeSftp: (onClose: () => void) => void +): Promise { + return new Promise((resolve, reject) => { + let settled = false + let abortError: (Error & { sshChannelCloseConfirmed: boolean }) | null = null + let closeGraceTimer: ReturnType | null = null + const settle = (fn: typeof resolve | typeof reject, value: T | Error): void => { + if (settled) { + return + } + settled = true + if (closeGraceTimer) { + clearTimeout(closeGraceTimer) + } + signal.removeEventListener('abort', onAbort) + fn(value as never) + } + const onAbort = (): void => { + // Why: rejecting the caller is insufficient; ending SFTP stops the + // abandoned transfer from mutating a successor relay install. + abortError = Object.assign(createSshOperationAbortError(), { + sshChannelCloseConfirmed: false + }) + closeGraceTimer = setTimeout(() => settle(reject, abortError!), 5_000) + closeSftp(() => { + abortError!.sshChannelCloseConfirmed = true + settle(reject, abortError!) + }) + } + signal.addEventListener('abort', onAbort, { once: true }) + void operation.then( + (value) => { + if (!abortError) { + settle(resolve, value) + } + }, + (error: unknown) => { + if (!abortError) { + settle(reject, error instanceof Error ? error : new Error(String(error))) + } + } + ) + if (signal.aborted) { + onAbort() + } + }) +} diff --git a/src/main/ssh/ssh-relay-build-toolchain.ts b/src/main/ssh/ssh-relay-build-toolchain.ts index cdbdb676441..208deceb6e0 100644 --- a/src/main/ssh/ssh-relay-build-toolchain.ts +++ b/src/main/ssh/ssh-relay-build-toolchain.ts @@ -141,15 +141,20 @@ export function formatMissingToolchainError( // callers fall back to the original install error in those cases. export async function probeBuildToolchain( conn: SshConnection, - hostPlatform: RemoteHostPlatform + hostPlatform: RemoteHostPlatform, + signal?: AbortSignal ): Promise { if (isWindowsRemoteHost(hostPlatform)) { return null } try { - const output = await execCommand(conn, buildToolchainProbeCommand(), { wrapCommand: true }) + const output = await execCommand(conn, buildToolchainProbeCommand(), { + wrapCommand: true, + signal + }) return parseBuildToolchainProbe(output) } catch { + signal?.throwIfAborted() return null } } diff --git a/src/main/ssh/ssh-relay-cross-version-isolation.test.ts b/src/main/ssh/ssh-relay-cross-version-isolation.test.ts index a29801c3632..14f063e3bf1 100644 --- a/src/main/ssh/ssh-relay-cross-version-isolation.test.ts +++ b/src/main/ssh/ssh-relay-cross-version-isolation.test.ts @@ -33,6 +33,9 @@ vi.mock('./ssh-relay-deploy-helpers', () => ({ onData: vi.fn(), onClose: vi.fn() }), + isUnconfirmedSshCommandTermination: (error: unknown) => + error instanceof Error && + (error as Error & { sshChannelCloseConfirmed?: boolean }).sshChannelCloseConfirmed === false, execCommand: vi.fn() })) @@ -101,8 +104,10 @@ describe('cross-version isolation', () => { '__ORCA_REMOTE_PLATFORM__ Linux x86_64', // tagged POSIX platform probe '/home/u', // echo $HOME 'MISSING', // isRelayAlreadyInstalled (v2 dir doesn't exist) + 'OPEN', // no sibling GC claim '', // mkdir -p remoteRelayDir (v2) 'OK', // mkdir lock OK + 'OPEN', // GC did not claim while the install lock was acquired 'MISSING', // re-probe after lock → still missing → proceed with install '', // mkdir remoteDir (uploadRelay) '', // chmod +x node @@ -111,9 +116,9 @@ describe('cross-version isolation', () => { 'ORCA-NPTY-PROBE-OK\n', // node -e require() load-test (post-install verify) '', // rm -f probe-stderr (best-effort cleanup after probe resolved) '', // touch .install-complete (finalizeInstall) - '', // rm -rf .install-lock 'DEAD', // launch socket probe 'READY', // socket poll + '', // release .install-lock after relay liveness is observable // GC scan begins here 'relay-0.1.0+v1hash\nrelay-0.1.0+v2hash\n', // ls listing 'OPEN', // v1 lock probe (siblings only — current dir is v2) diff --git a/src/main/ssh/ssh-relay-deploy-helpers.test.ts b/src/main/ssh/ssh-relay-deploy-helpers.test.ts index cb5477091d6..4df9c8ad28a 100644 --- a/src/main/ssh/ssh-relay-deploy-helpers.test.ts +++ b/src/main/ssh/ssh-relay-deploy-helpers.test.ts @@ -11,9 +11,10 @@ import { function createMockChannel(): ClientChannel { return Object.assign(new EventEmitter(), { - stderr: new EventEmitter(), + stderr: Object.assign(new EventEmitter(), { resume: vi.fn() }), stdin: { write: vi.fn() }, - close: vi.fn() + close: vi.fn(), + resume: vi.fn() }) as unknown as ClientChannel } @@ -29,6 +30,44 @@ async function execCommandRejection(promise: Promise): Promise { } describe('waitForSentinel', () => { + it('closes and rejects with AbortError while clearing startup resources on abort', async () => { + vi.useFakeTimers() + try { + const channel = createMockChannel() + const controller = new AbortController() + const removeListener = vi.spyOn(controller.signal, 'removeEventListener') + const transportPromise = waitForSentinel(channel, controller.signal) + + expect(vi.getTimerCount()).toBe(1) + controller.abort() + + await expect(transportPromise).rejects.toMatchObject({ name: 'AbortError' }) + expect(channel.close).toHaveBeenCalledTimes(1) + expect(removeListener).toHaveBeenCalledWith('abort', expect.any(Function)) + expect(vi.getTimerCount()).toBe(0) + } finally { + vi.useRealTimers() + } + }) + + it('removes the abort listener and timers when startup fails before the sentinel', async () => { + vi.useFakeTimers() + try { + const channel = createMockChannel() + const controller = new AbortController() + const removeListener = vi.spyOn(controller.signal, 'removeEventListener') + const transportPromise = waitForSentinel(channel, controller.signal) + + channel.emit('error', new Error('remote host rebooted')) + + await expect(transportPromise).rejects.toThrow('remote host rebooted') + expect(removeListener).toHaveBeenCalledWith('abort', expect.any(Function)) + expect(vi.getTimerCount()).toBe(0) + } finally { + vi.useRealTimers() + } + }) + it('buffers post-sentinel chunks until the transport subscribes', async () => { const channel = createMockChannel() const transportPromise = waitForSentinel(channel) @@ -168,6 +207,32 @@ describe('waitForSentinel', () => { }) describe('execCommand', () => { + it('waits for channel close before rejecting a timed-out remote command', async () => { + vi.useFakeTimers() + try { + const channel = createMockChannel() + const conn = { exec: vi.fn().mockResolvedValue(channel) } + const commandPromise = execCommand(conn as never, 'npm rebuild', { timeoutMs: 1_000 }) + + await vi.advanceTimersByTimeAsync(1_000) + expect(channel.close).toHaveBeenCalledTimes(1) + expect( + await Promise.race([ + commandPromise.then( + () => 'settled', + () => 'settled' + ), + Promise.resolve('pending') + ]) + ).toBe('pending') + + channel.emit('close', 0) + await expect(commandPromise).rejects.toThrow('timed out after 1s') + } finally { + vi.useRealTimers() + } + }) + it('rejects on command channel errors instead of emitting uncaught errors', async () => { const channel = createMockChannel() const conn = { @@ -177,6 +242,8 @@ describe('execCommand', () => { await Promise.resolve() expect(() => channel.emit('error', new Error('remote host rebooted'))).not.toThrow() + expect(channel.close).toHaveBeenCalledOnce() + channel.emit('close', 1) await expect(commandPromise).rejects.toThrow('remote host rebooted') expect(channel.listenerCount('error')).toBe(0) expect(channel.listenerCount('data')).toBe(0) @@ -224,6 +291,22 @@ describe('execCommand', () => { expect(error.message).toContain('gnu++20') }) + it('bounds command output while preserving the actionable tail', async () => { + const channel = createMockChannel() + const conn = { exec: vi.fn().mockResolvedValue(channel) } + const commandPromise = execCommand(conn as never, 'npm rebuild') + + await Promise.resolve() + channel.emit('data', Buffer.from(`old-prefix-${'x'.repeat(1024 * 1024)}`)) + channel.emit('data', Buffer.from('gyp ERR! tail diagnosis')) + channel.emit('close', 1) + + const error = await execCommandRejection(commandPromise) + expect(error.message).not.toContain('old-prefix') + expect(error.message).toContain('gyp ERR! tail diagnosis') + expect(error.message.length).toBeLessThan(1024 * 1024 + 200) + }) + it('keeps the merged error message greppable by the build-toolchain probe', async () => { const channel = createMockChannel() const conn = { @@ -260,14 +343,26 @@ describe('execCommand', () => { await Promise.resolve() const rejection = expect(commandPromise).rejects.toThrow('timed out') await vi.advanceTimersByTimeAsync(30_000) + expect(channel.close).toHaveBeenCalledOnce() + await vi.advanceTimersByTimeAsync(5_000) await rejection expect(channel.close).toHaveBeenCalledOnce() - expect(channel.listenerCount('error')).toBe(0) + expect(channel.resume).toHaveBeenCalledOnce() + expect(channel.stderr.resume).toHaveBeenCalledOnce() + expect(() => channel.emit('error', new Error('late channel error'))).not.toThrow() + expect(() => channel.stderr.emit('error', new Error('late stderr error'))).not.toThrow() + channel.emit('data', Buffer.from('late stdout')) + channel.stderr.emit('data', Buffer.from('late stderr')) + expect(channel.listenerCount('error')).toBe(1) expect(channel.listenerCount('data')).toBe(0) + expect(channel.listenerCount('close')).toBe(1) + expect(channel.stderr.listenerCount('error')).toBe(1) + expect(channel.stderr.listenerCount('data')).toBe(0) + channel.emit('close', 0) + expect(channel.listenerCount('error')).toBe(0) expect(channel.listenerCount('close')).toBe(0) expect(channel.stderr.listenerCount('error')).toBe(0) - expect(channel.stderr.listenerCount('data')).toBe(0) } finally { vi.useRealTimers() } @@ -309,6 +404,48 @@ describe('execCommand', () => { expect(channel.stderr.listenerCount('data')).toBe(0) }) + it('keeps system-SSH command termination unconfirmed after the local child closes', async () => { + const channel = createMockChannel() + const controller = new AbortController() + const conn = { + exec: vi.fn().mockResolvedValue(channel), + usesSystemSshTransport: vi.fn().mockReturnValueOnce(true).mockReturnValue(false) + } + const commandPromise = execCommand(conn as never, 'npm install', { + signal: controller.signal + }) + + await Promise.resolve() + controller.abort() + channel.emit('close', 0) + + await expect(commandPromise).rejects.toMatchObject({ + name: 'AbortError', + sshChannelCloseConfirmed: false + }) + }) + + it('tags an externally closed system-SSH command as unconfirmed', async () => { + const channel = createMockChannel() as ClientChannel & { _closeRequested?: boolean } + channel.close = vi.fn(() => { + channel._closeRequested = true + }) + const conn = { + exec: vi.fn().mockResolvedValue(channel), + usesSystemSshTransport: vi.fn().mockReturnValue(true) + } + const commandPromise = execCommand(conn as never, 'npm install') + + await Promise.resolve() + channel.close() + channel.emit('close', 1) + + await expect(commandPromise).rejects.toMatchObject({ + name: 'AbortError', + sshChannelCloseConfirmed: false + }) + }) + it('handles aborts that happen while the SSH exec channel is still opening', async () => { const channel = createMockChannel() const controller = new AbortController() @@ -357,6 +494,8 @@ describe('execCommand', () => { expect(conn.exec).toHaveBeenCalledWith('npm install', { wrapCommand: false }) const rejection = expect(commandPromise).rejects.toThrow('timed out after 240s') await vi.advanceTimersByTimeAsync(240_000) + expect(channel.close).toHaveBeenCalledOnce() + await vi.advanceTimersByTimeAsync(5_000) await rejection expect(channel.close).toHaveBeenCalledOnce() diff --git a/src/main/ssh/ssh-relay-deploy-helpers.ts b/src/main/ssh/ssh-relay-deploy-helpers.ts index 1b1cdd27074..80d2b78ced2 100644 --- a/src/main/ssh/ssh-relay-deploy-helpers.ts +++ b/src/main/ssh/ssh-relay-deploy-helpers.ts @@ -1,18 +1,21 @@ import type { ClientChannel } from 'ssh2' -import type { SshConnection } from './ssh-connection' -import { createSshOperationAbortError, type SshExecOptions } from './ssh-connection-utils' +import { createSshOperationAbortError } from './ssh-connection-utils' import { RELAY_SENTINEL, RELAY_SENTINEL_TIMEOUT_MS } from './relay-protocol' import type { MultiplexerTransport } from './ssh-channel-multiplexer' import { buildRelayVersionMismatchError } from './ssh-relay-handshake-mismatch' export { uploadFile, uploadDirectory, mkdirSftp } from './sftp-upload' +export { execCommand, isUnconfirmedSshCommandTermination } from './ssh-relay-exec-command' // ── Sentinel detection ──────────────────────────────────────────────── const MAX_RELAY_STARTUP_BUFFER_BYTES = 64 * 1024 const RELAY_SENTINEL_BUFFER = Buffer.from(RELAY_SENTINEL, 'utf-8') -export function waitForSentinel(channel: ClientChannel): Promise { +export function waitForSentinel( + channel: ClientChannel, + signal?: AbortSignal +): Promise { return new Promise((resolve, reject) => { let sentinelReceived = false let settled = false @@ -39,17 +42,14 @@ export function waitForSentinel(channel: ClientChannel): Promise { timeoutFired = true - channel.close() timeoutGraceTimer = setTimeout(() => { - if (!settled) { - settled = true - reject( - new Error( - `Relay failed to start within ${RELAY_SENTINEL_TIMEOUT_MS / 1000}s.${stderrOutput ? ` stderr: ${stderrOutput.trim()}` : ''}` - ) + rejectStartup( + new Error( + `Relay failed to start within ${RELAY_SENTINEL_TIMEOUT_MS / 1000}s.${stderrOutput ? ` stderr: ${stderrOutput.trim()}` : ''}` ) - } + ) }, TIMEOUT_GRACE_MS) + channel.close() }, RELAY_SENTINEL_TIMEOUT_MS) const cancelTimers = (): void => { @@ -59,6 +59,31 @@ export function waitForSentinel(channel: ClientChannel): Promise { + cancelTimers() + signal?.removeEventListener('abort', onAbort) + } + const rejectStartup = (err: Error): void => { + if (settled) { + return + } + settled = true + cleanupStartup() + reject(err) + } + const onAbort = (): void => { + if (settled) { + return + } + settled = true + cleanupStartup() + channel.close() + reject(createSshOperationAbortError()) + } + signal?.addEventListener('abort', onAbort, { once: true }) + if (signal?.aborted) { + onAbort() + } channel.on('exit', (code: number | null) => { if (typeof code === 'number') { @@ -87,12 +112,8 @@ export function waitForSentinel(channel: ClientChannel): Promise { - cancelTimers() if (!sentinelReceived) { - if (!settled) { - settled = true - reject(err) - } + rejectStartup(err) return } notifyClosed() @@ -113,9 +134,7 @@ export function waitForSentinel(channel: ClientChannel): Promise { if (!sentinelReceived) { - cancelTimers() if (!settled) { - settled = true // Why: a wire-handshake mismatch on the daemon side closes the // socket; --connect prints the mismatch detail to stderr and exits // with code 42 BEFORE writing the sentinel. Translate that into a @@ -126,13 +145,13 @@ export function waitForSentinel(channel: ClientChannel): Promise 0) { pendingAfterSentinel = afterSentinel } - settled = true - const transport: MultiplexerTransport = { write: (buf: Buffer) => channel.stdin.write(buf), onData: (cb) => { @@ -233,97 +251,3 @@ export function waitForSentinel(channel: ClientChannel): Promise { - const { timeoutMs = EXEC_TIMEOUT_MS, ...execOptions } = options ?? {} - const signal = options?.signal - if (signal?.aborted) { - throw createSshOperationAbortError() - } - const channel = await conn.exec(command, execOptions) - return new Promise((resolve, reject) => { - let stdout = '' - let stderr = '' - let settled = false - - const cleanup = (): void => { - clearTimeout(timeout) - signal?.removeEventListener('abort', onAbort) - channel.off('error', fail) - channel.stderr.off('error', fail) - channel.off('data', onStdoutData) - channel.stderr.off('data', onStderrData) - channel.off('close', onClose) - } - const settle = (fn: typeof resolve | typeof reject, val: string | Error): void => { - if (settled) { - return - } - settled = true - cleanup() - fn(val as never) - } - const fail = (err: Error): void => { - settle(reject, abortRequested ? createSshOperationAbortError() : err) - } - // Why: sshd counts the session against MaxSessions until CHANNEL_CLOSE - // completes. Settling on abort before the channel actually closes lets the - // concurrent-bootstrap sequential fallback reissue an exec while the slot - // is still held, so it gets refused again. Close and settle from onClose. - let abortRequested = false - const onAbort = (): void => { - abortRequested = true - channel.close() - } - const onStdoutData = (data: Buffer): void => { - stdout += data.toString('utf-8') - } - const onStderrData = (data: Buffer): void => { - stderr += data.toString('utf-8') - } - const onClose = (code: number): void => { - if (abortRequested) { - settle(reject, createSshOperationAbortError()) - } else if (code !== 0) { - // Why: on the system-ssh transport channel.stderr carries local OpenSSH - // client noise; preferring it masks the real failure in stdout (2>&1). - const output = [stderr.trim(), stdout.trim()].filter(Boolean).join('\n') - settle(reject, new Error(`Command "${command}" failed (exit ${code}): ${output}`)) - } else { - settle(resolve, stdout) - } - } - const timeout = setTimeout(() => { - channel.close() - settle( - reject, - abortRequested - ? createSshOperationAbortError() - : new Error(`Command "${command}" timed out after ${timeoutMs / 1000}s`) - ) - }, timeoutMs) - - // Why: remote reboot tears down exec channels with stream errors. Without - // scoped listeners, Node treats those as uncaught exceptions. - signal?.addEventListener('abort', onAbort, { once: true }) - channel.on('error', fail) - channel.stderr.on('error', fail) - channel.on('data', onStdoutData) - channel.stderr.on('data', onStderrData) - channel.on('close', onClose) - if (signal?.aborted) { - onAbort() - } - }) -} diff --git a/src/main/ssh/ssh-relay-deploy-timing.ts b/src/main/ssh/ssh-relay-deploy-timing.ts new file mode 100644 index 00000000000..5dc24f937f8 --- /dev/null +++ b/src/main/ssh/ssh-relay-deploy-timing.ts @@ -0,0 +1,13 @@ +// npm install on a cold Windows cache plus antivirus scanning can exceed the +// default 30s exec timeout. +export const NATIVE_DEPS_COMMAND_TIMEOUT_MS = 240_000 + +// Why: a missing binding can require both install and rebuild while the same +// install lock is held. Concurrent first installs must wait for that valid +// holder instead of failing halfway through its bounded work. +const NATIVE_DEPS_REPAIR_BUDGET_MS = 2 * NATIVE_DEPS_COMMAND_TIMEOUT_MS + +// Why: native repair also runs separately bounded chmod/probe/diagnostic/ +// finalize commands around install + rebuild. Keep the outer bound above that +// valid worst path while remaining below the 20-minute stale-lock threshold. +export const RELAY_DEPLOY_TIMEOUT_MS = NATIVE_DEPS_REPAIR_BUDGET_MS + 7 * 60_000 diff --git a/src/main/ssh/ssh-relay-deploy.test.ts b/src/main/ssh/ssh-relay-deploy.test.ts index 33467819035..28fe2a3e49c 100644 --- a/src/main/ssh/ssh-relay-deploy.test.ts +++ b/src/main/ssh/ssh-relay-deploy.test.ts @@ -42,6 +42,9 @@ vi.mock('./ssh-relay-deploy-helpers', () => ({ onData: vi.fn(), onClose: vi.fn() }), + isUnconfirmedSshCommandTermination: (error: unknown) => + error instanceof Error && + (error as Error & { sshChannelCloseConfirmed?: boolean }).sshChannelCloseConfirmed === false, execCommand: vi.fn().mockResolvedValue('__ORCA_REMOTE_PLATFORM__ Linux x86_64') })) @@ -49,27 +52,38 @@ vi.mock('./ssh-remote-node-resolution', () => ({ resolveRemoteNodePath: vi.fn().mockResolvedValue('/usr/bin/node') })) -// Why: the versioned-install module shells out to the remote for install -// state, lock acquisition, and GC. Tests stub these to no-ops so the deploy -// happy-path is exercised without a real SSH connection. +// Why: the versioned-install modules shell out for install state, locking, +// and GC. Stub them so deploy tests need no real SSH connection. vi.mock('./ssh-relay-versioned-install', () => ({ readLocalFullVersion: vi.fn().mockReturnValue('0.1.0+abcdef012345'), computeRemoteRelayDir: (home: string, v: string) => `${home}/.orca-remote/relay-${v}`, isRelayAlreadyInstalled: vi.fn().mockResolvedValue(true), - acquireInstallLock: vi.fn().mockResolvedValue(undefined), finalizeInstall: vi.fn().mockResolvedValue(undefined), abandonInstall: vi.fn().mockResolvedValue(undefined), gcOldRelayVersions: vi.fn().mockResolvedValue(undefined) })) +vi.mock('./ssh-relay-install-lock', () => ({ + acquireInstallLock: vi.fn().mockResolvedValue(undefined) +})) + +vi.mock('./ssh-relay-repair-lock', () => ({ + tryAcquireRelayRepairLock: vi.fn().mockResolvedValue('acquired') +})) + vi.mock('./ssh-connection-utils', () => ({ - shellEscape: (s: string) => `'${s}'` + shellEscape: (s: string) => `'${s}'`, + createSshOperationAbortError: () => + Object.assign(new Error('SSH operation was cancelled'), { + name: 'AbortError' + }) })) import { deployAndLaunchRelay } from './ssh-relay-deploy' import { execCommand, waitForSentinel } from './ssh-relay-deploy-helpers' import { resolveRemoteNodePath } from './ssh-remote-node-resolution' import { isRelayAlreadyInstalled } from './ssh-relay-versioned-install' +import { acquireInstallLock } from './ssh-relay-install-lock' import type { SshConnection } from './ssh-connection' import type * as SshRemoteNodeResolution from './ssh-remote-node-resolution' import { @@ -133,7 +147,8 @@ describe('deployAndLaunchRelay', () => { expect(mockExecCommand).toHaveBeenCalledWith( conn, - "printf '\\n%s ' '__ORCA_REMOTE_PLATFORM__'; uname -sm" + "printf '\\n%s ' '__ORCA_REMOTE_PLATFORM__'; uname -sm", + expect.objectContaining({ signal: expect.any(AbortSignal) }) ) }) @@ -153,6 +168,26 @@ describe('deployAndLaunchRelay', () => { expect(progress).toContain('Starting relay...') }) + it('does not launch fresh after unconfirmed stale-socket cleanup', async () => { + const conn = makeMockConnection() + const unconfirmedCleanup = Object.assign(new Error('socket cleanup still running'), { + sshChannelCloseConfirmed: false + }) + vi.mocked(waitForSentinel).mockRejectedValueOnce(new Error('stale relay reconnect failed')) + vi.mocked(execCommand) + .mockResolvedValueOnce('__ORCA_REMOTE_PLATFORM__ Linux x86_64') + .mockResolvedValueOnce('/home/user') + .mockResolvedValueOnce('ORCA-NATIVE-DEPS-OK') + .mockResolvedValueOnce('ALIVE') + .mockRejectedValueOnce(unconfirmedCleanup) + + await expect(deployAndLaunchRelay(conn)).rejects.toBe(unconfirmedCleanup) + + const commands = vi.mocked(conn.exec).mock.calls.map(([command]) => command) + expect(commands).toHaveLength(1) + expect(commands.some((command) => command.includes('--detached'))).toBe(false) + }) + it('resolves the remote node path once per deploy', async () => { const conn = makeMockConnection() const mockExecCommand = vi.mocked(execCommand) @@ -202,7 +237,7 @@ describe('deployAndLaunchRelay', () => { assertionError = err } finally { // Drain the rest of the happy path so a failed assertion does not leave - // the deploy promise pending until its 300s timeout. + // the deploy promise pending until the overall deploy timeout. mockExecCommand.mockResolvedValueOnce('ORCA-NATIVE-DEPS-OK') // native deps probe mockExecCommand.mockResolvedValueOnce('DEAD') // socket probe mockExecCommand.mockResolvedValueOnce('READY') // socket poll @@ -287,7 +322,10 @@ describe('deployAndLaunchRelay', () => { await deployAndLaunchRelay(conn) - expect(isRelayAlreadyInstalled).toHaveBeenCalledTimes(2) + expect(isRelayAlreadyInstalled).toHaveBeenCalledTimes(3) + expect(vi.mocked(isRelayAlreadyInstalled).mock.calls[2]?.[3]).toMatchObject({ + rethrowSessionLimitErrors: true + }) expect(resolveRemoteNodePath).toHaveBeenCalledTimes(2) }) @@ -314,11 +352,17 @@ describe('deployAndLaunchRelay', () => { await deployAndLaunchRelay(conn) - expect(isRelayAlreadyInstalled).toHaveBeenCalledTimes(2) + expect(isRelayAlreadyInstalled).toHaveBeenCalledTimes(3) expect(vi.mocked(isRelayAlreadyInstalled).mock.calls[0]?.[3]).toMatchObject({ rethrowSessionLimitErrors: true }) - expect(vi.mocked(isRelayAlreadyInstalled).mock.calls[1]?.[3]).toBeUndefined() + expect(vi.mocked(isRelayAlreadyInstalled).mock.calls[1]?.[3]).toMatchObject({ + rethrowSessionLimitErrors: undefined, + signal: expect.any(AbortSignal) + }) + expect(vi.mocked(isRelayAlreadyInstalled).mock.calls[2]?.[3]).toMatchObject({ + rethrowSessionLimitErrors: true + }) expect(resolveRemoteNodePath).toHaveBeenCalledTimes(2) }) @@ -499,7 +543,10 @@ describe('deployAndLaunchRelay', () => { expect(sawLegacyDir).toBe(false) }) - it('has a 300-second overall timeout', async () => { + it('bounds the overall deploy so install + rebuild both fit under the timeout', async () => { + // Why: the outer bound must exceed the worst-case sequential native-deps + // work — a first install (240s) AND a follow-up rebuild (240s) — so a + // legitimate install-then-rebuild is not falsely timed out mid-repair. const conn = makeMockConnection() const mockExecCommand = vi.mocked(execCommand) @@ -511,15 +558,140 @@ describe('deployAndLaunchRelay', () => { // Catch the rejection immediately to avoid unhandled rejection warning const promise = deployAndLaunchRelay(conn).catch((err: Error) => err) + // Not timed out yet at the old 300s bound (install + rebuild need more). await vi.advanceTimersByTimeAsync(301_000) + expect(await Promise.race([promise, Promise.resolve('pending')])).toBe('pending') + + await vi.advanceTimersByTimeAsync(600_000) const result = await promise expect(result).toBeInstanceOf(Error) - expect((result as Error).message).toBe('Relay deployment timed out after 300s') + expect((result as Error).message).toBe('Relay deployment timed out after 900s') vi.useRealTimers() }) + it('aborts a contended install-lock wait at the overall deploy timeout', async () => { + vi.useFakeTimers() + try { + const conn = makeMockConnection() + vi.mocked(execCommand) + .mockResolvedValueOnce('__ORCA_REMOTE_PLATFORM__ Linux x86_64') + .mockResolvedValueOnce('/home/user') + vi.mocked(isRelayAlreadyInstalled).mockResolvedValueOnce(false) + let lockSignal: AbortSignal | undefined + vi.mocked(acquireInstallLock).mockImplementationOnce((_conn, _dir, _host, options) => { + lockSignal = options?.signal + return new Promise((_resolve, reject) => { + lockSignal?.addEventListener('abort', () => reject(lockSignal?.reason), { once: true }) + }) + }) + + const promise = deployAndLaunchRelay(conn).catch((err: Error) => err) + await vi.advanceTimersByTimeAsync(0) + expect(acquireInstallLock).toHaveBeenCalledTimes(1) + + await vi.advanceTimersByTimeAsync(900_000) + + const result = await promise + expect(result).toBeInstanceOf(Error) + expect((result as Error).message).toBe('Relay deployment timed out after 900s') + expect(lockSignal?.aborted).toBe(true) + } finally { + vi.useRealTimers() + } + }) + + it('aborts an in-progress relay upload at the overall deploy timeout', async () => { + vi.useFakeTimers() + try { + const conn = makeMockConnection() + vi.mocked(execCommand) + .mockResolvedValueOnce('__ORCA_REMOTE_PLATFORM__ Linux x86_64') + .mockResolvedValueOnce('/home/user') + .mockResolvedValueOnce('') // mkdir remote relay dir + vi.mocked(isRelayAlreadyInstalled).mockResolvedValueOnce(false).mockResolvedValueOnce(false) + let uploadSignal: AbortSignal | undefined + conn.uploadDirectory = vi.fn((_localDir, _remoteDir, options) => { + uploadSignal = options?.signal + return new Promise((_resolve, reject) => { + uploadSignal?.addEventListener('abort', () => reject(uploadSignal?.reason), { + once: true + }) + }) + }) + + const promise = deployAndLaunchRelay(conn).catch((err: Error) => err) + await vi.advanceTimersByTimeAsync(0) + expect(conn.uploadDirectory).toHaveBeenCalledTimes(1) + + await vi.advanceTimersByTimeAsync(900_000) + + const result = await promise + expect(result).toBeInstanceOf(Error) + expect((result as Error).message).toBe('Relay deployment timed out after 900s') + expect(uploadSignal?.aborted).toBe(true) + } finally { + vi.useRealTimers() + } + }) + + it('aborts a launch started near the deploy deadline and closes its channel once', async () => { + vi.useFakeTimers() + try { + const launchChannel = { + on: vi.fn(), + stderr: { on: vi.fn() }, + stdin: {}, + stdout: { on: vi.fn() }, + close: vi.fn() + } + const conn = makeMockConnection() + vi.mocked(conn.exec).mockResolvedValue(launchChannel as never) + const mockExecCommand = vi.mocked(execCommand) + mockExecCommand + .mockResolvedValueOnce('__ORCA_REMOTE_PLATFORM__ Linux x86_64') + .mockResolvedValueOnce('/home/user') + .mockImplementationOnce( + () => + new Promise((resolve) => + setTimeout(() => resolve('ORCA-NATIVE-DEPS-OK'), 899_900) + ) + ) + .mockResolvedValueOnce('DEAD') + .mockImplementationOnce((_conn, _command, options) => { + return new Promise((_resolve, reject) => { + options?.signal?.addEventListener( + 'abort', + () => { + const error = new Error('SSH operation was cancelled') + error.name = 'AbortError' + reject(error) + }, + { once: true } + ) + }) + }) + + const promise = deployAndLaunchRelay(conn).catch((err: Error) => err) + await vi.advanceTimersByTimeAsync(899_900) + expect(conn.exec).toHaveBeenCalledTimes(1) + expect(launchChannel.close).not.toHaveBeenCalled() + + await vi.advanceTimersByTimeAsync(100) + + const result = await promise + expect(result).toBeInstanceOf(Error) + expect((result as Error).message).toBe('Relay deployment timed out after 900s') + expect(launchChannel.close).toHaveBeenCalledTimes(1) + expect(mockExecCommand).toHaveBeenCalledTimes(5) + await vi.advanceTimersByTimeAsync(10_000) + expect(mockExecCommand).toHaveBeenCalledTimes(5) + } finally { + vi.useRealTimers() + } + }) + it('uses distinct target-specific relay socket paths', async () => { const connA = makeMockConnection() const connB = makeMockConnection() @@ -596,6 +768,20 @@ describe('deployAndLaunchRelay', () => { expect(launchScript).not.toContain('\\\\.\\pipe\\agent-hooks') const waitScript = decodedScripts.find((script) => script.includes('deadline=Date.now()')) ?? '' expect(waitScript).toContain('setTimeout(attempt,intervalMs)') + const windowsLaunchCalls = mockExecCommand.mock.calls.filter(([, command]) => { + const script = decodePowerShellCommand(command) + return ( + script?.includes('.windows-active-pipe') || + script?.includes('Invoke-CimMethod') || + script?.includes('deadline=Date.now()') + ) + }) + expect(windowsLaunchCalls.length).toBeGreaterThan(0) + expect( + windowsLaunchCalls.every(([, , options]) => options?.signal instanceof AbortSignal) + ).toBe(true) + expect(vi.mocked(conn.exec).mock.calls[0]?.[1]?.signal).toBeInstanceOf(AbortSignal) + expect(vi.mocked(waitForSentinel).mock.calls[0]?.[1]).toBeInstanceOf(AbortSignal) }) it('relaunches Windows remotes on a fallback pipe when reconnecting the occupied pipe fails', async () => { diff --git a/src/main/ssh/ssh-relay-deploy.ts b/src/main/ssh/ssh-relay-deploy.ts index 26501a8371b..56779b87f0f 100644 --- a/src/main/ssh/ssh-relay-deploy.ts +++ b/src/main/ssh/ssh-relay-deploy.ts @@ -8,18 +8,30 @@ import { app } from 'electron' import type { SshConnection } from './ssh-connection' import type { RelayPlatform } from './relay-protocol' import type { MultiplexerTransport } from './ssh-channel-multiplexer' -import { uploadDirectory, waitForSentinel, execCommand } from './ssh-relay-deploy-helpers' +import { + uploadDirectory, + waitForSentinel, + execCommand, + isUnconfirmedSshCommandTermination +} from './ssh-relay-deploy-helpers' import { resolveRemoteNodePath } from './ssh-remote-node-resolution' import { readLocalFullVersion, computeRemoteRelayDir, isRelayAlreadyInstalled, - acquireInstallLock, finalizeInstall, abandonInstall, gcOldRelayVersions } from './ssh-relay-versioned-install' -import { shellEscape } from './ssh-connection-utils' +import { acquireInstallLock } from './ssh-relay-install-lock' +import { tryAcquireRelayRepairLock } from './ssh-relay-repair-lock' +import { + releaseRelayGcClaimWithRetry, + tryAcquireRelayGcClaim, + waitForRelayGcClaimRelease +} from './ssh-relay-gc-claim' +import { NATIVE_DEPS_COMMAND_TIMEOUT_MS, RELAY_DEPLOY_TIMEOUT_MS } from './ssh-relay-deploy-timing' +import { createSshOperationAbortError, shellEscape } from './ssh-connection-utils' import { probeBuildToolchain, formatMissingToolchainError, @@ -66,16 +78,14 @@ export type RelayDeployResult = { sockPath?: string } -// Why: individual exec commands have 30s timeouts, but the full deploy -// pipeline (detect platform → check existing → upload → npm install → -// launch) has no overall bound. A hanging `npm install` or slow SFTP -// upload could block the connection indefinitely. First-time installs need -// room for the longer native dependency install bound below. -const RELAY_DEPLOY_TIMEOUT_MS = 300_000 - -// npm install on a cold Windows cache plus antivirus scanning can exceed the -// default 30s exec timeout. -const NATIVE_DEPS_INSTALL_TIMEOUT_MS = 240_000 +class RelayDirectoryGcConflictError extends Error { + constructor( + readonly remoteRelayDir: string, + readonly hostPlatform: RemoteHostPlatform + ) { + super(`Relay directory GC is in progress at ${remoteRelayDir}`) + } +} function execHostCommand( conn: SshConnection, @@ -108,15 +118,25 @@ export async function deployAndLaunchRelay( relayInstanceId?: string ): Promise { let timeoutHandle: ReturnType + // Why: Promise.race does not cancel its loser. Stop a contended install-lock + // waiter so it cannot acquire and mutate the relay after this call times out. + const deployAbortController = new AbortController() const timeoutPromise = new Promise((_resolve, reject) => { timeoutHandle = setTimeout(() => { + deployAbortController.abort() reject(new Error(`Relay deployment timed out after ${RELAY_DEPLOY_TIMEOUT_MS / 1000}s`)) }, RELAY_DEPLOY_TIMEOUT_MS) }) try { return await Promise.race([ - deployAndLaunchRelayInner(conn, onProgress, graceTimeSeconds, relayInstanceId), + deployAndLaunchRelayInner( + conn, + onProgress, + graceTimeSeconds, + relayInstanceId, + deployAbortController.signal + ), timeoutPromise ]) } finally { @@ -179,22 +199,29 @@ type RelayBootstrapState = { async function resolveRelayBootstrapStateSequentially( conn: SshConnection, hostPlatform: RemoteHostPlatform, - fullVersion: string + fullVersion: string, + signal?: AbortSignal ): Promise { - const installState = await resolveRemoteInstallState(conn, hostPlatform, fullVersion) - const nodePath = await resolveRemoteNodePath(conn, hostPlatform) + const installState = await resolveRemoteInstallState(conn, hostPlatform, fullVersion, { signal }) + const nodePath = await resolveRemoteNodePath(conn, hostPlatform, { signal }) return { ...installState, nodePath } } async function resolveRelayBootstrapState( conn: SshConnection, hostPlatform: RemoteHostPlatform, - fullVersion: string + fullVersion: string, + signal?: AbortSignal ): Promise { if (!conn.canRunConcurrentExecCommands()) { - return resolveRelayBootstrapStateSequentially(conn, hostPlatform, fullVersion) + return resolveRelayBootstrapStateSequentially(conn, hostPlatform, fullVersion, signal) } const abortController = new AbortController() + const abortForDeploy = (): void => abortController.abort() + signal?.addEventListener('abort', abortForDeploy, { once: true }) + if (signal?.aborted) { + abortForDeploy() + } const installStatePromise = resolveRemoteInstallState(conn, hostPlatform, fullVersion, { rethrowSessionLimitErrors: true, signal: abortController.signal @@ -205,10 +232,12 @@ async function resolveRelayBootstrapState( }) try { const [installState, nodePath] = await Promise.all([installStatePromise, nodePathPromise]) + signal?.throwIfAborted() return { ...installState, nodePath } } catch (err) { abortController.abort() const settled = await Promise.allSettled([installStatePromise, nodePathPromise]) + signal?.throwIfAborted() if (!isSshSessionLimitError(err)) { throw err } @@ -224,7 +253,9 @@ async function resolveRelayBootstrapState( console.warn( '[ssh-relay] Concurrent bootstrap probes hit the remote SSH session limit; retrying sequentially.' ) - return resolveRelayBootstrapStateSequentially(conn, hostPlatform, fullVersion) + return resolveRelayBootstrapStateSequentially(conn, hostPlatform, fullVersion, signal) + } finally { + signal?.removeEventListener('abort', abortForDeploy) } } @@ -241,11 +272,40 @@ async function deployAndLaunchRelayInner( conn: SshConnection, onProgress?: (status: string) => void, graceTimeSeconds?: number, - relayInstanceId?: string + relayInstanceId?: string, + deploySignal?: AbortSignal +): Promise { + while (true) { + deploySignal?.throwIfAborted() + try { + return await deployAndLaunchRelayAttempt( + conn, + onProgress, + graceTimeSeconds, + relayInstanceId, + deploySignal + ) + } catch (err) { + if (!(err instanceof RelayDirectoryGcConflictError)) { + throw err + } + // Why: GC atomically moves the old install aside. Wait for its stable + // sibling claim to clear, then recompute install state from scratch. + await waitForRelayGcClaimRelease(conn, err.remoteRelayDir, err.hostPlatform, deploySignal) + } + } +} + +async function deployAndLaunchRelayAttempt( + conn: SshConnection, + onProgress?: (status: string) => void, + graceTimeSeconds?: number, + relayInstanceId?: string, + deploySignal?: AbortSignal ): Promise { onProgress?.('Detecting remote platform...') console.log('[ssh-relay] Detecting remote platform...') - const hostPlatform = await detectRemoteHostPlatform(conn) + const hostPlatform = await detectRemoteHostPlatform(conn, { signal: deploySignal }) if (!hostPlatform) { throw new Error( 'Unsupported remote platform. Orca relay supports: linux-x64, linux-arm64, darwin-x64, darwin-arm64, win32-x64, win32-arm64.' @@ -275,57 +335,104 @@ async function deployAndLaunchRelayInner( // fail-fast; remotes that reject overlapping session channels retry the old // sequential order so restrictive SSH servers keep working. const { remoteHome, remoteRelayDir, alreadyInstalled, nodePath } = - await resolveRelayBootstrapState(conn, hostPlatform, fullVersion) + await resolveRelayBootstrapState(conn, hostPlatform, fullVersion, deploySignal) console.log(`[ssh-relay] Remote dir: ${remoteRelayDir}`) console.log(`[ssh-relay] Already installed at ${fullVersion}: ${alreadyInstalled}`) + let ownsInstallLock = false + let launchGcClaimToken: string | undefined if (alreadyInstalled) { - await repairInstalledNativeDeps(conn, remoteRelayDir, platform, hostPlatform, nodePath) + const launchFence = await repairInstalledNativeDeps( + conn, + remoteRelayDir, + platform, + hostPlatform, + nodePath, + deploySignal + ) + ownsInstallLock = launchFence.ownsInstallLock + launchGcClaimToken = launchFence.gcClaimToken + deploySignal?.throwIfAborted() } else { // Why: serialize concurrent first-installs of the same version against - // each other via an atomic mkdir lock. The losing caller polls and either + // each other via a host-native exclusive lock. The losing caller polls and either // re-checks `alreadyInstalled` (now true) or steals a stale lock. - await acquireInstallLock(conn, remoteRelayDir, hostPlatform) + await acquireInstallLock(conn, remoteRelayDir, hostPlatform, { signal: deploySignal }) + ownsInstallLock = true try { // Re-probe after acquiring the lock — a sibling installer may have // finished while we were waiting. - if (!(await isRelayAlreadyInstalled(conn, remoteRelayDir, hostPlatform))) { + if ( + !(await isRelayAlreadyInstalled(conn, remoteRelayDir, hostPlatform, { + signal: deploySignal + })) + ) { onProgress?.('Uploading relay...') console.log('[ssh-relay] Uploading relay...') - await uploadRelay(conn, platform, remoteRelayDir, fullVersion, hostPlatform) + await uploadRelay(conn, platform, remoteRelayDir, fullVersion, hostPlatform, deploySignal) console.log('[ssh-relay] Upload complete') onProgress?.('Installing native dependencies...') console.log('[ssh-relay] Installing native dependencies...') - await installNativeDeps(conn, remoteRelayDir, platform, hostPlatform, nodePath) + await installNativeDeps( + conn, + remoteRelayDir, + platform, + hostPlatform, + nodePath, + deploySignal + ) console.log('[ssh-relay] Native deps installed') - // Why: write `.install-complete` BEFORE releasing the lock so a - // sibling never observes the dir as "complete but locked", which - // would lead GC to skip a recoverable dir indefinitely. - await finalizeInstall(conn, remoteRelayDir, hostPlatform) - } else { - await abandonInstall(conn, remoteRelayDir, hostPlatform) + // Why: mark complete but retain the lock until launch makes daemon + // liveness observable to cross-version GC. + await finalizeInstall(conn, remoteRelayDir, hostPlatform, { + signal: deploySignal, + releaseLock: false + }) } } catch (err) { // Why: leave a partial install dir in place (no `.install-complete`) // so the next deploy detects the partial and re-runs upload + install. - // Just release the lock so a concurrent caller can retry. - await abandonInstall(conn, remoteRelayDir, hostPlatform) + // Keep the lock if remote command termination was not confirmed; stale + // recovery is safer than overlapping a still-running npm process. + if (!isUnconfirmedSshCommandTermination(err)) { + await abandonInstall(conn, remoteRelayDir, hostPlatform) + ownsInstallLock = false + } throw err } } - onProgress?.('Starting relay...') - console.log('[ssh-relay] Launching relay...') - const launched = await launchRelay( - conn, - remoteRelayDir, - hostPlatform, - nodePath, - graceTimeSeconds, - relayInstanceId - ) + let launched: Awaited> + let launchLivenessObserved = false + try { + deploySignal?.throwIfAborted() + onProgress?.('Starting relay...') + console.log('[ssh-relay] Launching relay...') + launched = await launchRelay( + conn, + remoteRelayDir, + hostPlatform, + nodePath, + graceTimeSeconds, + relayInstanceId, + deploySignal + ) + launchLivenessObserved = true + } finally { + // Why: older clients understand only the install lock. If launch never + // becomes live, retain it for stale recovery so their GC cannot race a + // concurrent caller that was waiting behind this owner. + if (ownsInstallLock && launchLivenessObserved) { + await abandonInstall(conn, remoteRelayDir, hostPlatform) + } + // The detached start may outlive a timed-out SSH command. Preserve either + // fence on failed launch until stale recovery can prove the handoff ended. + if (launchGcClaimToken && launchLivenessObserved) { + await releaseRelayGcClaimWithRetry(conn, remoteRelayDir, launchGcClaimToken, hostPlatform) + } + } console.log('[ssh-relay] Relay started successfully') // Why: best-effort cleanup of unreferenced sibling version dirs. Errors @@ -352,7 +459,8 @@ async function uploadRelay( platform: RelayPlatform, remoteDir: string, fullVersion: string, - hostPlatform: RemoteHostPlatform + hostPlatform: RemoteHostPlatform, + signal?: AbortSignal ): Promise { const localRelayDir = getLocalRelayPath(platform) if (!localRelayDir || !existsSync(localRelayDir)) { @@ -363,16 +471,19 @@ async function uploadRelay( } // Create remote directory - await execHostCommand(conn, hostPlatform, makeRemoteDirectoryCommand(hostPlatform, remoteDir)) + await execHostCommand(conn, hostPlatform, makeRemoteDirectoryCommand(hostPlatform, remoteDir), { + signal + }) - await uploadDirectoryForConnection(conn, localRelayDir, remoteDir, hostPlatform) + await uploadDirectoryForConnection(conn, localRelayDir, remoteDir, hostPlatform, signal) // Make the node binary executable if (!isWindowsRemoteHost(hostPlatform)) { await execHostCommand( conn, hostPlatform, - makeRemoteExecutableCommand(hostPlatform, joinRemotePath(hostPlatform, remoteDir, 'node')) + makeRemoteExecutableCommand(hostPlatform, joinRemotePath(hostPlatform, remoteDir, 'node')), + { signal } ) } @@ -383,7 +494,8 @@ async function uploadRelay( conn, hostPlatform, joinRemotePath(hostPlatform, remoteDir, '.version'), - fullVersion + fullVersion, + signal ) } @@ -391,10 +503,11 @@ async function uploadDirectoryForConnection( conn: SshConnection, localRelayDir: string, remoteDir: string, - hostPlatform: RemoteHostPlatform + hostPlatform: RemoteHostPlatform, + signal?: AbortSignal ): Promise { if (typeof conn.uploadDirectory === 'function') { - await conn.uploadDirectory(localRelayDir, remoteDir, { hostPlatform }) + await conn.uploadDirectory(localRelayDir, remoteDir, { hostPlatform, signal }) return } @@ -410,10 +523,11 @@ async function writeRemoteFile( conn: SshConnection, hostPlatform: RemoteHostPlatform, remotePath: string, - contents: string + contents: string, + signal?: AbortSignal ): Promise { if (typeof conn.writeFile === 'function') { - await conn.writeFile(remotePath, contents, { hostPlatform }) + await conn.writeFile(remotePath, contents, { hostPlatform, signal }) return } @@ -438,31 +552,64 @@ const RELAY_NATIVE_DEPS = { '@parcel/watcher': '2.5.6' } as const -async function hasRequiredNativeDeps( +type RelayNativeDepName = keyof typeof RELAY_NATIVE_DEPS +const RELAY_NATIVE_DEP_NAMES = Object.keys(RELAY_NATIVE_DEPS) as RelayNativeDepName[] +const NATIVE_DEPS_MISSING_PREFIX = 'ORCA-NATIVE-DEPS-MISSING:' + +// Why: npm 12 blocks dependency lifecycle scripts unless each exact package +// version is approved, even when ignore-scripts is explicitly disabled. +const RELAY_NATIVE_DEP_SCRIPT_ALLOWLIST = Object.fromEntries( + Object.entries(RELAY_NATIVE_DEPS).map(([name, version]) => [`${name}@${version}`, true]) +) + +function nativeDepsProbeJs(successToken: string): string { + // Why: node-pty's Windows wrapper defers loading conpty.node until first + // spawn, so require("node-pty") alone cannot prove the binding is healthy. + const loadNodePty = + 'require("node-pty"); require("node-pty/lib/utils").loadNativeModule(process.platform==="win32"&&Number(require("os").release().split(".")[2])>=18309?"conpty":"pty")' + return `(()=>{const missing=[];try{${loadNodePty}}catch{missing.push("node-pty")}try{require("@parcel/watcher")}catch{missing.push("@parcel/watcher")}if(missing.length){console.log("${NATIVE_DEPS_MISSING_PREFIX}"+missing.join(","));process.exitCode=1}else{console.log(${JSON.stringify(successToken)})}})()` +} + +function missingNativeDepsFromProbe(output: string): RelayNativeDepName[] { + const marker = output + .split(/\r?\n/) + .find((line) => line.trim().startsWith(NATIVE_DEPS_MISSING_PREFIX)) + if (!marker) { + return [...RELAY_NATIVE_DEP_NAMES] + } + const reported = marker.trim().slice(NATIVE_DEPS_MISSING_PREFIX.length).split(',') + return RELAY_NATIVE_DEP_NAMES.filter((name) => reported.includes(name)) +} + +async function probeRequiredNativeDeps( conn: SshConnection, remoteDir: string, hostPlatform: RemoteHostPlatform, - nodePath: string -): Promise { + nodePath: string, + signal?: AbortSignal +): Promise<{ available: boolean; missing: RelayNativeDepName[] }> { const escapedNode = shellEscape(nodePath) + const probeJs = nativeDepsProbeJs('ORCA-NATIVE-DEPS-OK') try { const command = isWindowsRemoteHost(hostPlatform) ? commandWithNodePath( hostPlatform, nodePath, remoteDir, - `try { & ${powerShellLiteral(nodePath)} -e ${powerShellNativeArg('require.resolve("node-pty"); require.resolve("@parcel/watcher"); console.log("ORCA-NATIVE-DEPS-OK")')} } catch { 'MISSING' }` + `try { & ${powerShellLiteral(nodePath)} -e ${powerShellNativeArg(probeJs)} } catch { 'MISSING' }` ) : commandWithNodePath( hostPlatform, nodePath, remoteDir, - `(${escapedNode} -e 'require.resolve("node-pty"); require.resolve("@parcel/watcher"); console.log("ORCA-NATIVE-DEPS-OK")' 2>/dev/null || echo MISSING)` + `(${escapedNode} -e ${shellEscape(probeJs)} 2>/dev/null || echo MISSING)` ) - const probe = await execHostCommand(conn, hostPlatform, command) - return probe.includes('ORCA-NATIVE-DEPS-OK') + const probe = await execHostCommand(conn, hostPlatform, command, { signal }) + const available = probe.includes('ORCA-NATIVE-DEPS-OK') + return { available, missing: available ? [] : missingNativeDepsFromProbe(probe) } } catch { - return false + signal?.throwIfAborted() + return { available: false, missing: [...RELAY_NATIVE_DEP_NAMES] } } } @@ -471,25 +618,116 @@ async function repairInstalledNativeDeps( remoteDir: string, platform: RelayPlatform, hostPlatform: RemoteHostPlatform, - nodePath: string -): Promise { - if (await hasRequiredNativeDeps(conn, remoteDir, hostPlatform, nodePath)) { - return + nodePath: string, + signal?: AbortSignal +): Promise<{ ownsInstallLock: boolean; gcClaimToken?: string }> { + const initialProbe = await probeRequiredNativeDeps( + conn, + remoteDir, + hostPlatform, + nodePath, + signal + ) + const lockResult = await tryAcquireRelayRepairLock(conn, remoteDir, hostPlatform, { signal }) + if (lockResult === 'gc') { + throw new RelayDirectoryGcConflictError(remoteDir, hostPlatform) + } + if (lockResult === 'acquired') { + let stillInstalled: boolean + try { + stillInstalled = await isRelayAlreadyInstalled(conn, remoteDir, hostPlatform, { + rethrowSessionLimitErrors: true, + signal + }) + } catch (err) { + await abandonInstall(conn, remoteDir, hostPlatform) + throw err + } + if (!stillInstalled) { + // Why: GC may finish its rename before our lock attempt recreates the + // original path. Never trust probes made before this locked recheck. + await abandonInstall(conn, remoteDir, hostPlatform) + throw new RelayDirectoryGcConflictError(remoteDir, hostPlatform) + } + } + const gcClaimToken = + lockResult === 'busy' || lockResult === 'error' + ? await acquireRelayLaunchGcFence(conn, remoteDir, hostPlatform, signal) + : undefined + if (initialProbe.available) { + // Why: even a healthy reconnect must stay fenced until launch liveness is + // observable; otherwise cross-version GC can rename after this probe. + return { ownsInstallLock: lockResult === 'acquired', gcClaimToken } } + // Why: an already-installed relay can launch in degraded mode (fs/git/preflight + // still work; native-backed ops fail), so native-deps repair is best-effort: + // lock contention and repair failures must not abort the connection. Pre-repair + // this path used require.resolve (which passed for a present-but-unloadable + // binding), so a fatal repair here would be a straight regression. console.warn(`[ssh-relay] Repairing missing native deps at ${remoteDir}`) - await acquireInstallLock(conn, remoteDir, hostPlatform) + if (lockResult === 'busy' || lockResult === 'error') { + console.warn( + `[ssh-relay] Native-deps repair lock is ${lockResult} at ${remoteDir}; launching degraded` + ) + return { ownsInstallLock: false, gcClaimToken } + } try { // Why: older complete relay dirs were created before @parcel/watcher was // installed. Re-probe under the lock so only one reconnect mutates the dir. - if (!(await hasRequiredNativeDeps(conn, remoteDir, hostPlatform, nodePath))) { - await installNativeDeps(conn, remoteDir, platform, hostPlatform, nodePath) - await finalizeInstall(conn, remoteDir, hostPlatform) - } else { - await abandonInstall(conn, remoteDir, hostPlatform) + const probe = await probeRequiredNativeDeps(conn, remoteDir, hostPlatform, nodePath, signal) + if (!probe.available) { + await installNativeDeps( + conn, + remoteDir, + platform, + hostPlatform, + nodePath, + signal, + probe.missing + ) + await finalizeInstall(conn, remoteDir, hostPlatform, { signal, releaseLock: false }) } + return { ownsInstallLock: true } } catch (err) { - await abandonInstall(conn, remoteDir, hostPlatform) + const terminationUnconfirmed = isUnconfirmedSshCommandTermination(err) + // Why: keep a confirmed-failure lock through degraded launch so GC cannot + // move the relay before liveness is visible. Unconfirmed remote mutation + // keeps its stale-recoverable lock beyond this connection. + console.warn( + `[ssh-relay] Native deps repair failed at ${remoteDir}; launching degraded: ${ + err instanceof Error ? err.message : String(err) + }` + ) + return { ownsInstallLock: !terminationUnconfirmed } + } +} + +async function acquireRelayLaunchGcFence( + conn: SshConnection, + remoteDir: string, + hostPlatform: RemoteHostPlatform, + signal?: AbortSignal +): Promise { + const token = await tryAcquireRelayGcClaim(conn, remoteDir, hostPlatform, signal) + if (!token) { + signal?.throwIfAborted() + throw new RelayDirectoryGcConflictError(remoteDir, hostPlatform) + } + try { + signal?.throwIfAborted() + const stillInstalled = await isRelayAlreadyInstalled(conn, remoteDir, hostPlatform, { + rethrowSessionLimitErrors: true, + signal + }) + if (!stillInstalled) { + throw new RelayDirectoryGcConflictError(remoteDir, hostPlatform) + } + // Why: a caller that cannot own the install lock still needs its own + // durable fence; never borrow another connection's lock through launch. + return token + } catch (err) { + await releaseRelayGcClaimWithRetry(conn, remoteDir, token, hostPlatform) throw err } } @@ -507,14 +745,14 @@ async function installNativeDeps( remoteDir: string, platform: RelayPlatform, hostPlatform: RemoteHostPlatform, - nodePath: string + nodePath: string, + signal?: AbortSignal, + resetDeps: RelayNativeDepName[] = [] ): Promise { - // Why: node's bin directory must be in PATH for npm's child processes. + // Why: commandWithNodePath puts node's bin directory in PATH for npm's child processes. // npm install runs node-pty's prebuild script (`node scripts/prebuild.js`) // which spawns `node` as a child — if node isn't in PATH, that child // fails with exit 127 even though we invoked npm via its full path. - const escapedNode = shellEscape(nodePath) - // npm init -y rejects '+' in derived package names (content-hashed dir // names like relay-0.1.0+abc123). Bypass it with a fixed minimal // package.json. type:commonjs pins module resolution against Node default @@ -524,38 +762,51 @@ async function installNativeDeps( version: '1.0.0', private: true, type: 'commonjs', - dependencies: RELAY_NATIVE_DEPS + dependencies: RELAY_NATIVE_DEPS, + allowScripts: RELAY_NATIVE_DEP_SCRIPT_ALLOWLIST })}\n` await writeRemoteFile( conn, hostPlatform, joinRemotePath(hostPlatform, remoteDir, 'package.json'), - pkgJson + pkgJson, + signal ) try { const installArgs = Object.entries(RELAY_NATIVE_DEPS) .map(([dep, version]) => shellEscape(`${dep}@${version}`)) .join(' ') + // Why: npm reports a present package as up to date even when one packaged + // native file was deleted. Reset only dependencies the probe found broken. + const resetCommand = resetNativeDepsCommand(hostPlatform, resetDeps) + const resetPrefix = resetCommand ? `${resetCommand}; ` : '' const command = isWindowsRemoteHost(hostPlatform) ? commandWithNodePath( hostPlatform, nodePath, remoteDir, - `npm install --omit=dev --no-audit --no-fund ${Object.entries(RELAY_NATIVE_DEPS) + `${resetPrefix}npm install --ignore-scripts=false --omit=dev --no-audit --no-fund ${Object.entries( + RELAY_NATIVE_DEPS + ) .map(([dep, version]) => powerShellLiteral(`${dep}@${version}`)) - .join(' ')}` + .join(' ')}; if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }` ) : commandWithNodePath( hostPlatform, nodePath, remoteDir, - `npm install --omit=dev --no-audit --no-fund ${installArgs} 2>&1` + `${resetPrefix}npm install --ignore-scripts=false --omit=dev --no-audit --no-fund ${installArgs} 2>&1` ) await execHostCommand(conn, hostPlatform, command, { - timeoutMs: NATIVE_DEPS_INSTALL_TIMEOUT_MS + timeoutMs: NATIVE_DEPS_COMMAND_TIMEOUT_MS, + signal }) } catch (err) { + if (isUnconfirmedSshCommandTermination(err)) { + throw err + } + signal?.throwIfAborted() // Don't write .install-complete on hard fail; reconnect retries on a // partial install. Greppable token so user bug reports paste something // searchable. @@ -568,7 +819,7 @@ async function installNativeDeps( // remote and replace node-gyp's opaque `not found: make` with an actionable // install hint instead of leaking the raw npm output to the user. if (platform.startsWith('linux') && shouldProbeBuildToolchainAfterNativeDepsFailure(msg)) { - const toolchain = await probeBuildToolchain(conn, hostPlatform) + const toolchain = await probeBuildToolchain(conn, hostPlatform, signal) if (toolchain?.toolchainMissing) { throw new Error(formatMissingToolchainError(toolchain, msg)) } @@ -576,54 +827,178 @@ async function installNativeDeps( throw err } - // SFTP doesn't preserve execute bits; node-pty's spawn-helper prebuild - // must be +x for posix_spawnp. - if (!isWindowsRemoteHost(hostPlatform)) { - await execHostCommand( - conn, - hostPlatform, - `find ${shellEscape(joinRemotePath(hostPlatform, remoteDir, 'node_modules/node-pty/prebuilds'))} -name spawn-helper -exec chmod +x {} + 2>/dev/null; true` - ) + await makeNodePtySpawnHelperExecutable(conn, remoteDir, hostPlatform, signal) + + let probe = await probeInstalledNativeDeps(conn, remoteDir, hostPlatform, nodePath, signal) + if (!probe.available) { + // Why: npm treats an already-present package as up to date, so enabling + // lifecycle scripts on install cannot repair a binding skipped earlier. + console.warn(`[ssh-relay] Rebuilding unloadable native deps at ${remoteDir}`) + let rebuilt = false + try { + await rebuildNativeDeps(conn, remoteDir, hostPlatform, nodePath, signal) + rebuilt = true + } catch (err) { + if (isUnconfirmedSshCommandTermination(err)) { + throw err + } + signal?.throwIfAborted() + console.warn( + `[ssh-relay][NATIVE-DEPS-REBUILD-FAIL] npm rebuild native deps failed at ${remoteDir} (${platform}): ${(err as Error).message}` + ) + } + signal?.throwIfAborted() + if (rebuilt) { + await makeNodePtySpawnHelperExecutable(conn, remoteDir, hostPlatform, signal) + probe = await probeInstalledNativeDeps(conn, remoteDir, hostPlatform, nodePath, signal) + } } - // node -e require() catches unloadable installs (wrong arch, missing - // prebuild, broken native binding) that test -d cannot. Stderr → file - // so .bashrc noise can't pollute the sentinel match; preserved for the - // [NPTY-MISSING] breadcrumb. MISSING is non-fatal by design — see - // docs/ssh-relay-versioned-install-dirs.md (relay still serves - // fs/git/preflight; only pty.spawn fails at runtime). - const PROBE_OK = 'ORCA-NPTY-PROBE-OK' - const stderrFile = joinRemotePath(hostPlatform, remoteDir, '.npty-probe.stderr') - const escapedStderr = shellEscape(stderrFile) - const probeCommand = isWindowsRemoteHost(hostPlatform) + // MISSING is non-fatal by design: the relay still serves fs/git/preflight; + // only native-backed operations fail on hosts that cannot build the addons. + if (!probe.available) { + console.warn( + `[ssh-relay][NPTY-MISSING] native deps installed but require() failed at ${remoteDir} (${platform}). stdout=${probe.output.trim().slice(-200)} stderr=${probe.stderr.trim().slice(-500)}` + ) + } +} + +function resetNativeDepsCommand( + hostPlatform: RemoteHostPlatform, + resetDeps: RelayNativeDepName[] +): string { + if (resetDeps.length === 0) { + return '' + } + const resetNodePty = resetDeps.includes('node-pty') + const resetWatcher = resetDeps.includes('@parcel/watcher') + if (isWindowsRemoteHost(hostPlatform)) { + const commands: string[] = [] + if (resetNodePty) { + commands.push( + `Remove-Item -LiteralPath ${powerShellLiteral('node_modules/node-pty')} -Recurse -Force -ErrorAction SilentlyContinue` + ) + } + if (resetWatcher) { + commands.push( + `Remove-Item -LiteralPath ${powerShellLiteral('node_modules/@parcel/watcher')} -Recurse -Force -ErrorAction SilentlyContinue`, + `$parcelScope = ${powerShellLiteral('node_modules/@parcel')}`, + `Get-ChildItem -LiteralPath $parcelScope -Force -ErrorAction SilentlyContinue | Where-Object { $_.Name.StartsWith('watcher-', [StringComparison]::Ordinal) } | Remove-Item -Recurse -Force -ErrorAction SilentlyContinue` + ) + } + return commands.join('; ') + } + const commands: string[] = [] + if (resetNodePty) { + commands.push(`rm -rf ${shellEscape('node_modules/node-pty')}`) + } + if (resetWatcher) { + commands.push( + `rm -rf ${shellEscape('node_modules/@parcel/watcher')}`, + `find ${shellEscape('node_modules/@parcel')} -maxdepth 1 -name 'watcher-*' -exec rm -rf {} + 2>/dev/null || true` + ) + } + return commands.join('; ') +} + +async function rebuildNativeDeps( + conn: SshConnection, + remoteDir: string, + hostPlatform: RemoteHostPlatform, + nodePath: string, + signal?: AbortSignal +): Promise { + const depNames = Object.keys(RELAY_NATIVE_DEPS) + const command = isWindowsRemoteHost(hostPlatform) ? commandWithNodePath( hostPlatform, nodePath, remoteDir, - `try { & ${powerShellLiteral(nodePath)} -e ${powerShellNativeArg('require("node-pty"); console.log(process.argv[1])')} ${powerShellLiteral(PROBE_OK)}; if ($LASTEXITCODE -ne 0) { 'MISSING' } } catch { 'MISSING' }` + `npm rebuild --ignore-scripts=false ${depNames.map(powerShellLiteral).join(' ')}; if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }` ) : commandWithNodePath( hostPlatform, nodePath, remoteDir, - `(${escapedNode} -e 'require("node-pty"); console.log(process.argv[1])' ${shellEscape(PROBE_OK)} 2>${escapedStderr} || echo MISSING)` + `npm rebuild --ignore-scripts=false ${depNames.map(shellEscape).join(' ')} 2>&1` ) - const probeOutput = await execHostCommand(conn, hostPlatform, probeCommand) - if (!probeOutput.includes(PROBE_OK)) { - const remoteStderr = isWindowsRemoteHost(hostPlatform) - ? '' - : await execHostCommand(conn, hostPlatform, `cat ${escapedStderr} 2>/dev/null; true`).catch( - () => '' - ) - console.warn( - `[ssh-relay][NPTY-MISSING] node-pty installed but require() failed at ${remoteDir} (${platform}). stdout=${probeOutput.trim().slice(-200)} stderr=${remoteStderr.trim().slice(-500)}` - ) + await execHostCommand(conn, hostPlatform, command, { + timeoutMs: NATIVE_DEPS_COMMAND_TIMEOUT_MS, + signal + }) +} + +async function makeNodePtySpawnHelperExecutable( + conn: SshConnection, + remoteDir: string, + hostPlatform: RemoteHostPlatform, + signal?: AbortSignal +): Promise { + if (isWindowsRemoteHost(hostPlatform)) { + return } + // SFTP doesn't preserve execute bits; node-pty's spawn-helper prebuild + // must be +x for posix_spawnp. await execHostCommand( conn, hostPlatform, - removeRemoteFileCommand(hostPlatform, stderrFile) - ).catch(() => {}) + `find ${shellEscape(joinRemotePath(hostPlatform, remoteDir, 'node_modules/node-pty/prebuilds'))} -name spawn-helper -exec chmod +x {} + 2>/dev/null; true`, + { signal } + ) +} + +async function probeInstalledNativeDeps( + conn: SshConnection, + remoteDir: string, + hostPlatform: RemoteHostPlatform, + nodePath: string, + signal?: AbortSignal +): Promise<{ + available: boolean + missing: RelayNativeDepName[] + output: string + stderr: string +}> { + // require() catches unloadable installs (wrong arch, missing prebuild, or a + // skipped lifecycle script) that require.resolve() and test -d both miss. + const PROBE_OK = 'ORCA-NPTY-PROBE-OK' + const stderrFile = joinRemotePath(hostPlatform, remoteDir, '.npty-probe.stderr') + const escapedStderr = shellEscape(stderrFile) + const probeJs = nativeDepsProbeJs(PROBE_OK) + const probeCommand = isWindowsRemoteHost(hostPlatform) + ? commandWithNodePath( + hostPlatform, + nodePath, + remoteDir, + `try { & ${powerShellLiteral(nodePath)} -e ${powerShellNativeArg(probeJs)} ${powerShellLiteral(PROBE_OK)}; if ($LASTEXITCODE -ne 0) { 'MISSING' } } catch { 'MISSING' }` + ) + : commandWithNodePath( + hostPlatform, + nodePath, + remoteDir, + `(${shellEscape(nodePath)} -e ${shellEscape(probeJs)} ${shellEscape(PROBE_OK)} 2>${escapedStderr} || echo MISSING)` + ) + const probeOutput = await execHostCommand(conn, hostPlatform, probeCommand, { signal }) + const remoteStderr = + probeOutput.includes(PROBE_OK) || isWindowsRemoteHost(hostPlatform) + ? '' + : await execHostCommand(conn, hostPlatform, `cat ${escapedStderr} 2>/dev/null; true`, { + signal + }).catch(() => '') + signal?.throwIfAborted() + if (!isWindowsRemoteHost(hostPlatform)) { + // The POSIX probe redirects stderr to this file; the Windows probe does not. + await execHostCommand(conn, hostPlatform, removeRemoteFileCommand(hostPlatform, stderrFile), { + signal + }).catch(() => {}) + signal?.throwIfAborted() + } + return { + available: probeOutput.includes(PROBE_OK), + missing: probeOutput.includes(PROBE_OK) ? [] : missingNativeDepsFromProbe(probeOutput), + output: probeOutput, + stderr: remoteStderr + } } function getLocalRelayPath(platform: RelayPlatform): string | null { @@ -663,7 +1038,8 @@ async function launchRelay( hostPlatform: RemoteHostPlatform, nodePath: string, graceTimeSeconds?: number, - relayInstanceId?: string + relayInstanceId?: string, + signal?: AbortSignal ): Promise<{ transport: MultiplexerTransport; nodePath: string; sockPath: string }> { // Why: Phase 1 of the plan requires Node.js on the remote. We use the // system `node` rather than bundling a node binary, keeping the relay @@ -695,21 +1071,27 @@ async function launchRelay( conn, hostPlatform, remoteDir, - activePipeMarkerPath + activePipeMarkerPath, + signal )) ?? { sockPath: sockFile, endpointDir } const fallbackEndpoint = buildWindowsRelayFallbackEndpoint(hostPlatform, remoteDir, sockName) - return launchWindowsRelay(conn, hostPlatform, { - remoteDir, - nodePath, - sockPath: activeEndpoint.sockPath, - endpointDir: activeEndpoint.endpointDir, - graceTime, - activePipeMarkerPath, - reconnectFallback: fallbackEndpoint - }) + return launchWindowsRelay( + conn, + hostPlatform, + { + remoteDir, + nodePath, + sockPath: activeEndpoint.sockPath, + endpointDir: activeEndpoint.endpointDir, + graceTime, + activePipeMarkerPath, + reconnectFallback: fallbackEndpoint + }, + signal + ) } // Why: after an app restart a relay may still be running in its grace @@ -719,29 +1101,43 @@ async function launchRelay( try { const probeOutput = await execCommand( conn, - `test -S ${shellEscape(sockFile)} && echo ALIVE || echo DEAD` + `test -S ${shellEscape(sockFile)} && echo ALIVE || echo DEAD`, + { signal } ) console.warn(`[ssh-relay] Socket probe result: "${probeOutput.trim()}"`) if (probeOutput.trim() === 'ALIVE') { console.log('[ssh-relay] Existing relay socket found, attempting reconnect...') try { const channel = await conn.exec( - `cd ${escapedDir} && ${escapedNode} relay.js --connect --sock-path ${shellEscape(sockFile)}` + `cd ${escapedDir} && ${escapedNode} relay.js --connect --sock-path ${shellEscape(sockFile)}`, + { signal } ) - const transport = await waitForSentinel(channel) + const transport = await waitForSentinel(channel, signal) console.log('[ssh-relay] Reconnected to existing relay via socket') return { transport, nodePath, sockPath: sockFile } } catch (err) { + signal?.throwIfAborted() console.warn( '[ssh-relay] Socket reconnect failed, launching fresh relay:', err instanceof Error ? err.message : String(err) ) // Why: stale socket from a crashed relay — remove it so the // fresh launch can bind a new socket at the same path. - await execCommand(conn, `rm -f ${shellEscape(sockFile)}`).catch(() => {}) + await execCommand(conn, `rm -f ${shellEscape(sockFile)}`, { signal }).catch( + (cleanupErr) => { + if (isUnconfirmedSshCommandTermination(cleanupErr)) { + throw cleanupErr + } + } + ) + signal?.throwIfAborted() } } - } catch { + } catch (err) { + if (isUnconfirmedSshCommandTermination(err)) { + throw err + } + signal?.throwIfAborted() // Probe failed — fall through to fresh launch } @@ -762,7 +1158,7 @@ async function launchRelay( // subsequent logging (it wraps process.stderr/stdout), so the current log // stays at relay.log for the `tail relay.log` diagnostics workflow. const launchCmd = `cd ${escapedDir} && nohup ${escapedNode} relay.js --detached --grace-time ${graceTime} --sock-path ${shellEscape(sockFile)} --log-file ${shellEscape(logFile)} > ${shellEscape(logFile)} 2>&1 {}) launchChannel.on('error', () => {}) launchChannel.stderr.on('data', () => {}) @@ -784,36 +1180,39 @@ async function launchRelay( const POLL_TIMEOUT_MS = 10_000 const pollStart = Date.now() let socketReady = false - while (Date.now() - pollStart < POLL_TIMEOUT_MS) { - try { - // Why: node is guaranteed to exist on the remote (we just deployed - // the relay with it). Using it to probe the socket is more portable - // than python3/socat/perl which may not be installed. The socket - // path is passed as argv[1] to avoid shell quoting issues with -e. - const result = await execCommand( - conn, - `${escapedNode} -e 'var s=require("net").connect(process.argv[1]);s.on("connect",function(){s.destroy();process.stdout.write("READY")});s.on("error",function(){process.stdout.write("WAITING")})' ${shellEscape(sockFile)} 2>/dev/null || (test -S ${shellEscape(sockFile)} && echo READY || echo WAITING)` - ) - if (result.trim() === 'READY') { - socketReady = true - break + try { + while (Date.now() - pollStart < POLL_TIMEOUT_MS) { + try { + // Why: node is guaranteed to exist on the remote (we just deployed + // the relay with it). Using it to probe the socket is more portable + // than python3/socat/perl which may not be installed. The socket + // path is passed as argv[1] to avoid shell quoting issues with -e. + const result = await execCommand( + conn, + `${escapedNode} -e 'var s=require("net").connect(process.argv[1]);s.on("connect",function(){s.destroy();process.stdout.write("READY")});s.on("error",function(){process.stdout.write("WAITING")})' ${shellEscape(sockFile)} 2>/dev/null || (test -S ${shellEscape(sockFile)} && echo READY || echo WAITING)`, + { signal } + ) + if (result.trim() === 'READY') { + socketReady = true + break + } + } catch { + signal?.throwIfAborted() + /* exec failed, retry */ } - } catch { - /* exec failed, retry */ + await waitForRelayPoll(POLL_INTERVAL_MS, signal) } - await new Promise((r) => setTimeout(r, POLL_INTERVAL_MS)) + } finally { + launchChannel.close() } - // Why: close the fire-and-forget launch channel now that the relay's - // socket is either ready or the poll timed out. Leaving it open leaks - // an SSH channel per relay restart. - launchChannel.close() - if (!socketReady) { const logOutput = await execCommand( conn, - `tail -20 ${shellEscape(logFile)} 2>/dev/null || echo "(no log)"` + `tail -20 ${shellEscape(logFile)} 2>/dev/null || echo "(no log)"`, + { signal } ).catch(() => '(could not read log)') + signal?.throwIfAborted() throw new Error(`Relay failed to start within ${POLL_TIMEOUT_MS / 1000}s. Log:\n${logOutput}`) } @@ -822,9 +1221,28 @@ async function launchRelay( // stdin/stdout to the relay's Unix socket — same path used for reconnect // after app restart. const channel = await conn.exec( - `cd ${escapedDir} && ${escapedNode} relay.js --connect --sock-path ${shellEscape(sockFile)}` + `cd ${escapedDir} && ${escapedNode} relay.js --connect --sock-path ${shellEscape(sockFile)}`, + { signal } ) - return { transport: await waitForSentinel(channel), nodePath, sockPath: sockFile } + return { transport: await waitForSentinel(channel, signal), nodePath, sockPath: sockFile } +} + +function waitForRelayPoll(delayMs: number, signal?: AbortSignal): Promise { + return new Promise((resolve, reject) => { + const onAbort = (): void => { + clearTimeout(timeout) + signal?.removeEventListener('abort', onAbort) + reject(createSshOperationAbortError()) + } + const timeout = setTimeout(() => { + signal?.removeEventListener('abort', onAbort) + resolve() + }, delayMs) + signal?.addEventListener('abort', onAbort, { once: true }) + if (signal?.aborted) { + onAbort() + } + }) } function buildWindowsRelayFallbackEndpoint( @@ -844,15 +1262,20 @@ async function readWindowsActiveRelayEndpoint( conn: SshConnection, hostPlatform: RemoteHostPlatform, remoteDir: string, - markerPath: string + markerPath: string, + signal?: AbortSignal ): Promise { const output = await execHostCommand( conn, hostPlatform, powerShellCommand( `if (Test-Path -LiteralPath ${powerShellLiteral(markerPath)} -PathType Leaf) { Get-Content -LiteralPath ${powerShellLiteral(markerPath)} -Raw -ErrorAction SilentlyContinue }` - ) - ).catch(() => '') + ), + { signal } + ).catch(() => { + signal?.throwIfAborted() + return '' + }) const sockPath = output.trim() if (!isWindowsRelayPipePath(sockPath)) { return null @@ -867,15 +1290,18 @@ async function rememberWindowsActiveRelayEndpoint( conn: SshConnection, hostPlatform: RemoteHostPlatform, markerPath: string, - sockPath: string + sockPath: string, + signal?: AbortSignal ): Promise { await execHostCommand( conn, hostPlatform, powerShellCommand( `Set-Content -LiteralPath ${powerShellLiteral(markerPath)} -Value ${powerShellLiteral(sockPath)} -NoNewline` - ) + ), + { signal } ).catch((err) => { + signal?.throwIfAborted() // Why: fallback pipe names are deterministic, so losing this marker does // not force the next deploy to orphan an undiscoverable relay. console.warn( @@ -901,17 +1327,19 @@ type WindowsRelayLaunchOptions = { async function launchWindowsRelay( conn: SshConnection, hostPlatform: RemoteHostPlatform, - opts: WindowsRelayLaunchOptions + opts: WindowsRelayLaunchOptions, + signal?: AbortSignal ): Promise<{ transport: MultiplexerTransport; nodePath: string; sockPath: string }> { let launchOpts = opts - if ((await probeWindowsRelayPipe(conn, hostPlatform, opts)) === 'READY') { + if ((await probeWindowsRelayPipe(conn, hostPlatform, opts, signal)) === 'READY') { try { - const transport = await connectWindowsRelay(conn, hostPlatform, opts) + const transport = await connectWindowsRelay(conn, hostPlatform, opts, signal) await rememberWindowsActiveRelayEndpoint( conn, hostPlatform, opts.activePipeMarkerPath, - opts.sockPath + opts.sockPath, + signal ) return { transport, @@ -919,6 +1347,7 @@ async function launchWindowsRelay( sockPath: opts.sockPath } } catch (err) { + signal?.throwIfAborted() console.warn( '[ssh-relay] Windows named pipe reconnect failed, launching fresh relay:', err instanceof Error ? err.message : String(err) @@ -936,15 +1365,16 @@ async function launchWindowsRelay( if ( launchOpts !== opts && - (await probeWindowsRelayPipe(conn, hostPlatform, launchOpts)) === 'READY' + (await probeWindowsRelayPipe(conn, hostPlatform, launchOpts, signal)) === 'READY' ) { try { - const transport = await connectWindowsRelay(conn, hostPlatform, launchOpts) + const transport = await connectWindowsRelay(conn, hostPlatform, launchOpts, signal) await rememberWindowsActiveRelayEndpoint( conn, hostPlatform, launchOpts.activePipeMarkerPath, - launchOpts.sockPath + launchOpts.sockPath, + signal ) return { transport, @@ -952,6 +1382,7 @@ async function launchWindowsRelay( sockPath: launchOpts.sockPath } } catch (err) { + signal?.throwIfAborted() console.warn( '[ssh-relay] Windows fallback pipe reconnect failed, relaunching relay:', err instanceof Error ? err.message : String(err) @@ -973,20 +1404,29 @@ async function launchWindowsRelay( launchOpts.graceTime, logFile, errFile - ) + ), + { signal } ) const POLL_INTERVAL_MS = 200 const POLL_TIMEOUT_MS = 10_000 if ( - await waitForWindowsRelayPipe(conn, hostPlatform, launchOpts, POLL_TIMEOUT_MS, POLL_INTERVAL_MS) + await waitForWindowsRelayPipe( + conn, + hostPlatform, + launchOpts, + POLL_TIMEOUT_MS, + POLL_INTERVAL_MS, + signal + ) ) { - const transport = await connectWindowsRelay(conn, hostPlatform, launchOpts) + const transport = await connectWindowsRelay(conn, hostPlatform, launchOpts, signal) await rememberWindowsActiveRelayEndpoint( conn, hostPlatform, launchOpts.activePipeMarkerPath, - launchOpts.sockPath + launchOpts.sockPath, + signal ) return { transport, @@ -998,8 +1438,12 @@ async function launchWindowsRelay( const logOutput = await execHostCommand( conn, hostPlatform, - windowsRelayTailLogCommand(logFile, errFile) - ).catch(() => '(could not read log)') + windowsRelayTailLogCommand(logFile, errFile), + { signal } + ).catch(() => { + signal?.throwIfAborted() + return '(could not read log)' + }) throw new Error(`Relay failed to start within ${POLL_TIMEOUT_MS / 1000}s. Log:\n${logOutput}`) } @@ -1010,13 +1454,14 @@ async function connectWindowsRelay( remoteDir: string nodePath: string sockPath: string - } + }, + signal?: AbortSignal ): Promise { const channel = await conn.exec( windowsRelayConnectCommand(hostPlatform, opts.nodePath, opts.remoteDir, opts.sockPath), - { wrapCommand: false } + { wrapCommand: false, signal } ) - return waitForSentinel(channel) + return waitForSentinel(channel, signal) } function windowsRelayConnectCommand( @@ -1083,12 +1528,14 @@ async function probeWindowsRelayPipe( remoteDir: string nodePath: string sockPath: string - } + }, + signal?: AbortSignal ): Promise<'READY' | 'WAITING'> { const result = await execHostCommand( conn, hostPlatform, - windowsRelayProbeCommand(hostPlatform, opts.nodePath, opts.remoteDir, opts.sockPath) + windowsRelayProbeCommand(hostPlatform, opts.nodePath, opts.remoteDir, opts.sockPath), + { signal } ) return result.trim() === 'READY' ? 'READY' : 'WAITING' } @@ -1102,7 +1549,8 @@ async function waitForWindowsRelayPipe( sockPath: string }, timeoutMs: number, - intervalMs: number + intervalMs: number, + signal?: AbortSignal ): Promise { try { const result = await execHostCommand( @@ -1111,10 +1559,12 @@ async function waitForWindowsRelayPipe( windowsRelayWaitCommand(hostPlatform, opts.nodePath, opts.remoteDir, opts.sockPath, { timeoutMs, intervalMs - }) + }), + { signal } ) return result.trim() === 'READY' } catch { + signal?.throwIfAborted() return false } } diff --git a/src/main/ssh/ssh-relay-exec-command.ts b/src/main/ssh/ssh-relay-exec-command.ts new file mode 100644 index 00000000000..e4bfd8bc042 --- /dev/null +++ b/src/main/ssh/ssh-relay-exec-command.ts @@ -0,0 +1,156 @@ +import type { SshConnection } from './ssh-connection' +import { createSshOperationAbortError, type SshExecOptions } from './ssh-connection-utils' +import type { SystemSshCommandChannel } from './system-ssh-command' + +const EXEC_TIMEOUT_MS = 30_000 +const COMMAND_CLOSE_GRACE_MS = 5_000 +const MAX_EXEC_OUTPUT_CHARS = 1024 * 1024 + +type ExecCommandOptions = SshExecOptions & { + timeoutMs?: number +} + +type SshCommandTerminationError = Error & { + sshChannelCloseConfirmed: boolean +} + +export function isUnconfirmedSshCommandTermination( + error: unknown +): error is SshCommandTerminationError { + return ( + error instanceof Error && + (error as Partial).sshChannelCloseConfirmed === false + ) +} + +export async function execCommand( + conn: SshConnection, + command: string, + options?: ExecCommandOptions +): Promise { + const { timeoutMs = EXEC_TIMEOUT_MS, ...execOptions } = options ?? {} + const signal = options?.signal + if (signal?.aborted) { + throw createSshOperationAbortError() + } + // Why: reconnect/disconnect can flip the connection back to ssh2 before a + // killed local OpenSSH child emits close; the channel's transport is immutable. + const openedWithSystemSsh = conn.usesSystemSshTransport?.() === true + const channel = await conn.exec(command, execOptions) + return new Promise((resolve, reject) => { + let stdout = '' + let stderr = '' + let settled = false + let terminationError: SshCommandTerminationError | null = null + let closeGraceTimer: ReturnType | null = null + + const cleanup = (): void => { + clearTimeout(timeout) + if (closeGraceTimer) { + clearTimeout(closeGraceTimer) + } + signal?.removeEventListener('abort', onAbort) + channel.off('error', fail) + channel.stderr.off('error', fail) + channel.off('data', onStdoutData) + channel.stderr.off('data', onStderrData) + channel.off('close', onClose) + } + const settle = (fn: typeof resolve | typeof reject, val: string | Error): void => { + if (settled) { + return + } + settled = true + cleanup() + fn(val as never) + } + const guardUnconfirmedTeardown = (): void => { + const swallowLateError = (): void => {} + const cleanupGuards = (): void => { + channel.off('error', swallowLateError) + channel.stderr.off('error', swallowLateError) + channel.off('close', cleanupGuards) + } + channel.on('error', swallowLateError) + channel.stderr.on('error', swallowLateError) + channel.once('close', cleanupGuards) + // Why: an unconfirmed close can arrive after the caller's bounded wait; + // keep draining discarded streams so ssh2 can finish CHANNEL_CLOSE. + channel.resume() + channel.stderr.resume() + } + // Why: sshd counts the session against MaxSessions until CHANNEL_CLOSE + // completes. Settling on abort before the channel actually closes lets the + // concurrent-bootstrap sequential fallback reissue an exec while the slot + // is still held, so it gets refused again. Close and settle from onClose. + const requestTermination = (error: Error): void => { + if (terminationError) { + return + } + terminationError = Object.assign(error, { sshChannelCloseConfirmed: false }) + clearTimeout(timeout) + // Why: callers must not release an install lock while its remote npm + // process can still mutate node_modules. Prefer confirmed channel close, + // but bound a broken transport's teardown wait. + closeGraceTimer = setTimeout(() => { + guardUnconfirmedTeardown() + settle(reject, error) + }, COMMAND_CLOSE_GRACE_MS) + channel.close() + } + const fail = (err: Error): void => requestTermination(err) + const onAbort = (): void => requestTermination(createSshOperationAbortError()) + const onStdoutData = (data: Buffer): void => { + stdout = appendExecOutputTail(stdout, data.toString('utf-8')) + } + const onStderrData = (data: Buffer): void => { + stderr = appendExecOutputTail(stderr, data.toString('utf-8')) + } + const onClose = (code: number): void => { + if ( + !terminationError && + openedWithSystemSsh && + (channel as SystemSshCommandChannel)._closeRequested + ) { + terminationError = Object.assign(createSshOperationAbortError(), { + sshChannelCloseConfirmed: false + }) + } + if (terminationError) { + // Why: a system-SSH channel closes when the local OpenSSH child exits; + // that does not prove the remote command stopped, especially with a ControlMaster. + if (!openedWithSystemSsh) { + terminationError.sshChannelCloseConfirmed = true + } + settle(reject, terminationError) + } else if (code !== 0) { + // Why: on the system-ssh transport channel.stderr carries local OpenSSH + // client noise; preferring it masks the real failure in stdout (2>&1). + const output = [stderr.trim(), stdout.trim()].filter(Boolean).join('\n') + settle(reject, new Error(`Command "${command}" failed (exit ${code}): ${output}`)) + } else { + settle(resolve, stdout) + } + } + const timeout = setTimeout(() => { + requestTermination(new Error(`Command "${command}" timed out after ${timeoutMs / 1000}s`)) + }, timeoutMs) + + // Why: remote reboot tears down exec channels with stream errors. Without + // scoped listeners, Node treats those as uncaught exceptions. + signal?.addEventListener('abort', onAbort, { once: true }) + channel.on('error', fail) + channel.stderr.on('error', fail) + channel.on('data', onStdoutData) + channel.stderr.on('data', onStderrData) + channel.on('close', onClose) + if (signal?.aborted) { + onAbort() + } + }) +} + +function appendExecOutputTail(existing: string, chunk: string): string { + const combined = existing + chunk + return combined.length > MAX_EXEC_OUTPUT_CHARS ? combined.slice(-MAX_EXEC_OUTPUT_CHARS) : combined +} diff --git a/src/main/ssh/ssh-relay-gc-claim.ts b/src/main/ssh/ssh-relay-gc-claim.ts new file mode 100644 index 00000000000..d0c8cc1221f --- /dev/null +++ b/src/main/ssh/ssh-relay-gc-claim.ts @@ -0,0 +1,236 @@ +import { randomUUID } from 'node:crypto' +import type { SshConnection } from './ssh-connection' +import { shellEscape } from './ssh-connection-utils' +import { execCommand } from './ssh-relay-deploy-helpers' +import { + probeInstallLockExistsCommand, + tryCreateInstallLockCommand, + tryStealInstallLockCommand +} from './ssh-relay-install-lock-commands' +import { removeRemoteTreeCommand } from './ssh-remote-commands' +import { powerShellCommand, powerShellLiteral } from './ssh-remote-powershell' +import { + getRemoteHostPlatform, + isWindowsRemoteHost, + joinRemotePath, + type RemoteHostPlatform +} from './ssh-remote-platform' + +const DEFAULT_REMOTE_HOST = getRemoteHostPlatform('linux-x64') +const RELAY_GC_CLAIM_SUFFIX = '.gc-claim' +const RELAY_GC_OWNER_NAME = '.gc-owner' +// Why: the claim guards only a bounded sibling rename or launch handoff, not +// npm or deletion. Ten minutes bounds crashes while exceeding either sequence. +const RELAY_GC_CLAIM_STALE_SECONDS = 10 * 60 + +function execHostCommand( + conn: SshConnection, + host: RemoteHostPlatform, + command: string, + signal?: AbortSignal +): Promise { + return execCommand(conn, command, { + wrapCommand: !isWindowsRemoteHost(host), + signal + }) +} + +export function relayGcClaimPath(remoteRelayDir: string): string { + return `${remoteRelayDir}${RELAY_GC_CLAIM_SUFFIX}` +} + +export async function isRelayGcClaimed( + conn: SshConnection, + remoteRelayDir: string, + host: RemoteHostPlatform = DEFAULT_REMOTE_HOST, + signal?: AbortSignal +): Promise { + const claimPath = relayGcClaimPath(remoteRelayDir) + const output = await execHostCommand( + conn, + host, + probeInstallLockExistsCommand(host, claimPath), + signal + ) + const markers = new Set( + output + .split(/\r?\n/) + .map((line) => line.trim()) + .filter((line) => line === 'LOCKED' || line === 'OPEN') + ) + if (markers.size !== 1) { + throw new Error(`Inconclusive relay GC claim probe at ${claimPath}`) + } + return markers.has('LOCKED') +} + +export async function tryAcquireRelayGcClaim( + conn: SshConnection, + remoteRelayDir: string, + host: RemoteHostPlatform = DEFAULT_REMOTE_HOST, + signal?: AbortSignal +): Promise { + const claimPath = relayGcClaimPath(remoteRelayDir) + try { + const created = await execHostCommand( + conn, + host, + tryCreateInstallLockCommand(host, claimPath), + signal + ) + if (created.trim().endsWith('OK')) { + return writeRelayGcClaimOwner(conn, remoteRelayDir, host, signal) + } + const stolen = await execHostCommand( + conn, + host, + tryStealInstallLockCommand(host, claimPath, RELAY_GC_CLAIM_STALE_SECONDS), + signal + ) + if (!stolen.trim().endsWith('OK')) { + return null + } + return writeRelayGcClaimOwner(conn, remoteRelayDir, host, signal) + } catch { + signal?.throwIfAborted() + return null + } +} + +async function writeRelayGcClaimOwner( + conn: SshConnection, + remoteRelayDir: string, + host: RemoteHostPlatform, + signal?: AbortSignal +): Promise { + const token = `${process.pid}-${Date.now()}-${randomUUID()}` + const claimPath = relayGcClaimPath(remoteRelayDir) + const ownerPath = joinRemotePath(host, claimPath, RELAY_GC_OWNER_NAME) + const command = isWindowsRemoteHost(host) + ? powerShellCommand( + `Set-Content -LiteralPath ${powerShellLiteral(ownerPath)} -Value ${powerShellLiteral(token)} -NoNewline -ErrorAction Stop` + ) + : `printf %s ${shellEscape(token)} > ${shellEscape(ownerPath)}` + try { + await execHostCommand(conn, host, command, signal) + return token + } catch { + // Why: the write may have succeeded remotely before SSH lost its reply. + // A conditional release removes only the claim generation with our token. + await releaseRelayGcClaim(conn, remoteRelayDir, token, host) + return null + } +} + +export async function isRelayGcClaimOwned( + conn: SshConnection, + remoteRelayDir: string, + token: string, + host: RemoteHostPlatform = DEFAULT_REMOTE_HOST +): Promise { + const ownerPath = joinRemotePath(host, relayGcClaimPath(remoteRelayDir), RELAY_GC_OWNER_NAME) + const command = isWindowsRemoteHost(host) + ? powerShellCommand( + `if ((Get-Content -LiteralPath ${powerShellLiteral(ownerPath)} -Raw -ErrorAction SilentlyContinue) -ceq ${powerShellLiteral(token)}) { 'OWNED' } else { 'LOST' }` + ) + : `test "$(cat ${shellEscape(ownerPath)} 2>/dev/null)" = ${shellEscape(token)} && echo OWNED || echo LOST` + const output = await execHostCommand(conn, host, command).catch(() => 'LOST') + return output.trim() === 'OWNED' +} + +export type RelayGcClaimReleaseResult = 'released' | 'lost' | 'unknown' + +export async function releaseRelayGcClaim( + conn: SshConnection, + remoteRelayDir: string, + token: string, + host: RemoteHostPlatform = DEFAULT_REMOTE_HOST +): Promise { + const claimPath = relayGcClaimPath(remoteRelayDir) + const ownerPath = joinRemotePath(host, claimPath, RELAY_GC_OWNER_NAME) + const command = isWindowsRemoteHost(host) + ? powerShellCommand( + `$claim = ${powerShellLiteral(claimPath)}; ` + + `if (-not (Test-Path -LiteralPath $claim)) { 'RELEASED' } ` + + `elseif ((Get-Content -LiteralPath ${powerShellLiteral(ownerPath)} -Raw -ErrorAction SilentlyContinue) -cne ${powerShellLiteral(token)}) { 'LOST' } ` + + 'else { try { Remove-Item -LiteralPath $claim -Recurse -Force -ErrorAction Stop } catch {}; ' + + "if (Test-Path -LiteralPath $claim) { 'UNKNOWN' } else { 'RELEASED' } }" + ) + : [ + `if ! test -e ${shellEscape(claimPath)}; then echo RELEASED;`, + `elif test "$(cat ${shellEscape(ownerPath)} 2>/dev/null)" != ${shellEscape(token)}; then echo LOST;`, + `else ${removeRemoteTreeCommand(host, claimPath)} 2>/dev/null;`, + `if test -e ${shellEscape(claimPath)}; then echo UNKNOWN; else echo RELEASED; fi; fi` + ].join(' ') + const output = await execHostCommand(conn, host, command).catch(() => 'UNKNOWN') + switch (output.trim()) { + case 'RELEASED': + return 'released' + case 'LOST': + return 'lost' + default: + return 'unknown' + } +} + +export async function releaseRelayGcClaimWithRetry( + conn: SshConnection, + remoteRelayDir: string, + token: string, + host: RemoteHostPlatform = DEFAULT_REMOTE_HOST +): Promise { + let result: RelayGcClaimReleaseResult = 'unknown' + for (let attempt = 0; attempt < 3 && result === 'unknown'; attempt++) { + result = await releaseRelayGcClaim(conn, remoteRelayDir, token, host) + } + return result +} + +export async function waitForRelayGcClaimRelease( + conn: SshConnection, + remoteRelayDir: string, + host: RemoteHostPlatform = DEFAULT_REMOTE_HOST, + signal?: AbortSignal +): Promise { + while (true) { + const claimed = await isRelayGcClaimed(conn, remoteRelayDir, host, signal).catch(() => true) + signal?.throwIfAborted() + if (!claimed) { + return + } + const claimPath = relayGcClaimPath(remoteRelayDir) + const recovered = await execHostCommand( + conn, + host, + tryStealInstallLockCommand(host, claimPath, RELAY_GC_CLAIM_STALE_SECONDS), + signal + ).catch(() => 'BUSY') + signal?.throwIfAborted() + if (recovered.trim().endsWith('OK')) { + const token = await writeRelayGcClaimOwner(conn, remoteRelayDir, host, signal) + if (token) { + const release = await releaseRelayGcClaim(conn, remoteRelayDir, token, host) + if (release === 'released') { + return + } + } + // A lost or uncertain release can mean a successor owns the stable path. + // Probe again instead of treating recovery as complete. + continue + } + await new Promise((resolve, reject) => { + const onAbort = (): void => { + clearTimeout(timer) + reject(signal?.reason) + } + const timer = setTimeout(() => { + signal?.removeEventListener('abort', onAbort) + resolve() + }, 1_000) + signal?.addEventListener('abort', onAbort, { once: true }) + if (signal?.aborted) { + onAbort() + } + }) + } +} diff --git a/src/main/ssh/ssh-relay-gc-retry.test.ts b/src/main/ssh/ssh-relay-gc-retry.test.ts new file mode 100644 index 00000000000..fee4946d356 --- /dev/null +++ b/src/main/ssh/ssh-relay-gc-retry.test.ts @@ -0,0 +1,275 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +vi.mock('electron', () => ({ app: { getAppPath: () => '/mock/app' } })) +vi.mock('fs', () => ({ + existsSync: vi.fn().mockReturnValue(true), + readFileSync: vi.fn().mockReturnValue('0.1.0+gc-retry') +})) +vi.mock('./relay-protocol', () => ({ + RELAY_VERSION: '0.1.0', + RELAY_REMOTE_DIR: '.orca-remote', + parseUnameToRelayPlatform: vi.fn().mockReturnValue('linux-x64'), + RELAY_SENTINEL: 'ORCA-RELAY v0.1.0 READY\n', + RELAY_SENTINEL_TIMEOUT_MS: 10_000 +})) +vi.mock('./ssh-relay-deploy-helpers', () => ({ + uploadDirectory: vi.fn(), + waitForSentinel: vi.fn().mockResolvedValue({ + write: vi.fn(), + onData: vi.fn(), + onClose: vi.fn() + }), + isUnconfirmedSshCommandTermination: () => false, + execCommand: vi.fn() +})) +vi.mock('./ssh-remote-node-resolution', () => ({ + resolveRemoteNodePath: vi.fn().mockResolvedValue('/usr/bin/node') +})) +vi.mock('./ssh-relay-versioned-install', () => ({ + readLocalFullVersion: vi.fn().mockReturnValue('0.1.0+gc-retry'), + computeRemoteRelayDir: (home: string, version: string) => `${home}/.orca-remote/relay-${version}`, + isRelayAlreadyInstalled: vi.fn().mockResolvedValue(true), + finalizeInstall: vi.fn(), + abandonInstall: vi.fn(), + gcOldRelayVersions: vi.fn().mockResolvedValue(undefined) +})) +vi.mock('./ssh-relay-install-lock', () => ({ acquireInstallLock: vi.fn() })) +vi.mock('./ssh-relay-repair-lock', () => ({ + tryAcquireRelayRepairLock: vi.fn().mockResolvedValue('acquired') +})) +vi.mock('./ssh-relay-gc-claim', () => ({ + releaseRelayGcClaimWithRetry: vi.fn().mockResolvedValue('released'), + tryAcquireRelayGcClaim: vi.fn().mockResolvedValue('launch-token'), + waitForRelayGcClaimRelease: vi.fn().mockResolvedValue(undefined) +})) +vi.mock('./ssh-connection-utils', () => ({ + shellEscape: (value: string) => `'${value}'`, + createSshOperationAbortError: () => Object.assign(new Error('cancelled'), { name: 'AbortError' }) +})) + +import { deployAndLaunchRelay } from './ssh-relay-deploy' +import { execCommand, waitForSentinel } from './ssh-relay-deploy-helpers' +import { + releaseRelayGcClaimWithRetry, + tryAcquireRelayGcClaim, + waitForRelayGcClaimRelease +} from './ssh-relay-gc-claim' +import { tryAcquireRelayRepairLock } from './ssh-relay-repair-lock' +import { abandonInstall, isRelayAlreadyInstalled } from './ssh-relay-versioned-install' +import type { SshConnection } from './ssh-connection' + +function makeConnection(): SshConnection { + const channel = { + on: vi.fn(), + stderr: { on: vi.fn() }, + close: vi.fn() + } + return { + canRunConcurrentExecCommands: vi.fn().mockReturnValue(true), + exec: vi.fn().mockResolvedValue(channel) + } as unknown as SshConnection +} + +describe('relay GC deploy retry', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('recomputes install state when GC wins before a healthy relay launch', async () => { + const conn = makeConnection() + vi.mocked(tryAcquireRelayRepairLock).mockResolvedValueOnce('gc') + vi.mocked(execCommand).mockImplementation(async (_conn, command) => { + if (command.includes('__ORCA_REMOTE_PLATFORM__')) { + return '__ORCA_REMOTE_PLATFORM__ Linux x86_64' + } + if (command === 'echo $HOME') { + return '/home/user' + } + if (command.includes('node-pty')) { + return 'ORCA-NATIVE-DEPS-OK' + } + if (command.includes('var s=require("net").connect')) { + return 'READY' + } + if (command.includes('test -S')) { + return 'DEAD' + } + return '' + }) + + await deployAndLaunchRelay(conn) + + expect(waitForRelayGcClaimRelease).toHaveBeenCalledTimes(1) + expect(isRelayAlreadyInstalled).toHaveBeenCalledTimes(3) + expect(conn.exec).toHaveBeenCalledTimes(2) + }) + + it('recomputes state when GC finishes between the install probe and lock acquisition', async () => { + const conn = makeConnection() + vi.mocked(isRelayAlreadyInstalled) + .mockResolvedValueOnce(true) + .mockResolvedValueOnce(false) + .mockResolvedValue(true) + vi.mocked(execCommand).mockImplementation(async (_conn, command) => { + if (command.includes('__ORCA_REMOTE_PLATFORM__')) { + return '__ORCA_REMOTE_PLATFORM__ Linux x86_64' + } + if (command === 'echo $HOME') { + return '/home/user' + } + if (command.includes('node-pty')) { + return 'ORCA-NATIVE-DEPS-OK' + } + if (command.includes('var s=require("net").connect')) { + return 'READY' + } + if (command.includes('test -S')) { + return 'DEAD' + } + return '' + }) + + await deployAndLaunchRelay(conn) + + expect(abandonInstall).toHaveBeenCalledTimes(2) + expect(waitForRelayGcClaimRelease).toHaveBeenCalledTimes(1) + expect(isRelayAlreadyInstalled).toHaveBeenCalledTimes(4) + expect(tryAcquireRelayRepairLock).toHaveBeenCalledTimes(2) + }) + + it('owns a GC claim through launch when the install-lock state is indeterminate', async () => { + const conn = makeConnection() + vi.mocked(tryAcquireRelayRepairLock).mockResolvedValueOnce('error') + let resolveReady: ((value: Awaited>) => void) | undefined + vi.mocked(waitForSentinel).mockImplementationOnce( + async () => + new Promise>>((resolve) => { + resolveReady = resolve + }) + ) + vi.mocked(execCommand).mockImplementation(async (_conn, command) => { + if (command.includes('__ORCA_REMOTE_PLATFORM__')) { + return '__ORCA_REMOTE_PLATFORM__ Linux x86_64' + } + if (command === 'echo $HOME') { + return '/home/user' + } + if (command.includes('node-pty')) { + return 'ORCA-NATIVE-DEPS-OK' + } + if (command.includes('var s=require("net").connect')) { + return 'READY' + } + if (command.includes('test -S')) { + return 'DEAD' + } + return '' + }) + + const deploy = deployAndLaunchRelay(conn) + await vi.waitFor(() => expect(resolveReady).toBeDefined()) + expect(releaseRelayGcClaimWithRetry).not.toHaveBeenCalled() + resolveReady?.({ + write: vi.fn(), + onData: vi.fn(), + onClose: vi.fn() + }) + await deploy + + expect(waitForRelayGcClaimRelease).not.toHaveBeenCalled() + expect(tryAcquireRelayRepairLock).toHaveBeenCalledTimes(1) + expect(tryAcquireRelayGcClaim).toHaveBeenCalledTimes(1) + expect(releaseRelayGcClaimWithRetry).toHaveBeenCalledTimes(1) + expect(conn.exec).toHaveBeenCalledTimes(2) + }) + + it('retains an owned install lock when launch never becomes live', async () => { + const conn = makeConnection() + vi.mocked(waitForSentinel).mockRejectedValueOnce(new Error('launch failed')) + vi.mocked(execCommand).mockImplementation(async (_conn, command) => { + if (command.includes('__ORCA_REMOTE_PLATFORM__')) { + return '__ORCA_REMOTE_PLATFORM__ Linux x86_64' + } + if (command === 'echo $HOME') { + return '/home/user' + } + if (command.includes('node-pty')) { + return 'ORCA-NATIVE-DEPS-OK' + } + if (command.includes('var s=require("net").connect')) { + return 'READY' + } + if (command.includes('test -S')) { + return 'DEAD' + } + return '' + }) + + await expect(deployAndLaunchRelay(conn)).rejects.toThrow('launch failed') + + expect(abandonInstall).not.toHaveBeenCalled() + expect(releaseRelayGcClaimWithRetry).not.toHaveBeenCalled() + }) + + it('retains an owned launch claim when launch never becomes live', async () => { + const conn = makeConnection() + vi.mocked(tryAcquireRelayRepairLock).mockResolvedValueOnce('busy') + vi.mocked(waitForSentinel).mockRejectedValueOnce(new Error('launch failed')) + vi.mocked(execCommand).mockImplementation(async (_conn, command) => { + if (command.includes('__ORCA_REMOTE_PLATFORM__')) { + return '__ORCA_REMOTE_PLATFORM__ Linux x86_64' + } + if (command === 'echo $HOME') { + return '/home/user' + } + if (command.includes('node-pty')) { + return 'ORCA-NATIVE-DEPS-OK' + } + if (command.includes('var s=require("net").connect')) { + return 'READY' + } + if (command.includes('test -S')) { + return 'DEAD' + } + return '' + }) + + await expect(deployAndLaunchRelay(conn)).rejects.toThrow('launch failed') + + expect(abandonInstall).not.toHaveBeenCalled() + expect(releaseRelayGcClaimWithRetry).not.toHaveBeenCalled() + }) + + it('keeps retrying repeated launch-claim contention within the deploy bound', async () => { + const conn = makeConnection() + vi.mocked(tryAcquireRelayRepairLock).mockResolvedValue('busy') + vi.mocked(tryAcquireRelayGcClaim) + .mockResolvedValueOnce(null) + .mockResolvedValueOnce(null) + .mockResolvedValue('launch-token') + vi.mocked(execCommand).mockImplementation(async (_conn, command) => { + if (command.includes('__ORCA_REMOTE_PLATFORM__')) { + return '__ORCA_REMOTE_PLATFORM__ Linux x86_64' + } + if (command === 'echo $HOME') { + return '/home/user' + } + if (command.includes('node-pty')) { + return 'ORCA-NATIVE-DEPS-OK' + } + if (command.includes('var s=require("net").connect')) { + return 'READY' + } + if (command.includes('test -S')) { + return 'DEAD' + } + return '' + }) + + await deployAndLaunchRelay(conn) + + expect(waitForRelayGcClaimRelease).toHaveBeenCalledTimes(2) + expect(tryAcquireRelayGcClaim).toHaveBeenCalledTimes(3) + expect(releaseRelayGcClaimWithRetry).toHaveBeenCalledTimes(1) + }) +}) diff --git a/src/main/ssh/ssh-relay-gc-tombstone.ts b/src/main/ssh/ssh-relay-gc-tombstone.ts new file mode 100644 index 00000000000..95684d4d20e --- /dev/null +++ b/src/main/ssh/ssh-relay-gc-tombstone.ts @@ -0,0 +1,33 @@ +import type { SshConnection } from './ssh-connection' +import { execCommand } from './ssh-relay-deploy-helpers' +import { removeRemoteTreeCommand } from './ssh-remote-commands' +import { + getRemoteHostPlatform, + isWindowsRemoteHost, + joinRemotePath, + type RemoteHostPlatform +} from './ssh-remote-platform' + +const DEFAULT_REMOTE_HOST = getRemoteHostPlatform('linux-x64') +const RELAY_GC_TOMBSTONE_REGEX = + /^relay-(?:v?\d+\.\d+\.\d+(?:\+[0-9a-f]+)?)\.gc-tombstone\.\d+\.\d+$/ + +export async function cleanupRelayGcTombstones( + conn: SshConnection, + baseDir: string, + entries: string[], + host: RemoteHostPlatform = DEFAULT_REMOTE_HOST +): Promise { + // Why: a confirmed rename isolates these paths from any recreated install. + // Strictly named leftovers are safe to retry after an interrupted GC pass. + for (const name of entries.filter((entry) => RELAY_GC_TOMBSTONE_REGEX.test(entry))) { + const tombstone = joinRemotePath(host, baseDir, name) + await execCommand(conn, removeRemoteTreeCommand(host, tombstone), { + wrapCommand: !isWindowsRemoteHost(host) + }).catch((err) => { + console.warn( + `[ssh-relay] GC failed to remove tombstone ${tombstone}: ${err instanceof Error ? err.message : String(err)}` + ) + }) + } +} diff --git a/src/main/ssh/ssh-relay-install-lock-commands.ts b/src/main/ssh/ssh-relay-install-lock-commands.ts new file mode 100644 index 00000000000..d6eab561a2d --- /dev/null +++ b/src/main/ssh/ssh-relay-install-lock-commands.ts @@ -0,0 +1,176 @@ +import { shellEscape } from './ssh-connection-utils' +import { powerShellCommand, powerShellLiteral } from './ssh-remote-powershell' +import { isWindowsRemoteHost, type RemoteHostPlatform } from './ssh-remote-platform' + +export function acquireInstallLockParentCommand( + host: RemoteHostPlatform, + remoteRelayDir: string +): string { + if (!isWindowsRemoteHost(host)) { + return `mkdir -p ${shellEscape(remoteRelayDir)}` + } + return powerShellCommand( + `$null = New-Item -ItemType Directory -Force -Path ${powerShellLiteral(remoteRelayDir)}` + ) +} + +export function tryCreateInstallLockCommand(host: RemoteHostPlatform, lockDir: string): string { + if (!isWindowsRemoteHost(host)) { + return `mkdir ${shellEscape(lockDir)} 2>&1 && echo OK || echo BUSY` + } + // Why: old Orca clients recognize only a directory at `.install-lock`, while + // concurrent New-Item calls can both report success in PowerShell 5.1. Keep + // that directory marker and arbitrate ownership with an atomic child file. + return powerShellCommand( + [ + `$lock = ${powerShellLiteral(lockDir)}`, + '$stream = $null', + 'try {', + "if (Test-Path -LiteralPath $lock) { 'BUSY' } else {", + '$null = New-Item -ItemType Directory -Path $lock -ErrorAction Stop', + "$owner = Join-Path $lock '.owner'", + '$stream = [System.IO.File]::Open($owner, [System.IO.FileMode]::CreateNew, [System.IO.FileAccess]::Write, [System.IO.FileShare]::None)', + "'OK'", + '}', + `} catch { 'BUSY' } finally { if ($null -ne $stream) { $stream.Dispose() } }` + ].join('; ') + ) +} + +export function probeInstallLockExistsCommand(host: RemoteHostPlatform, lockPath: string): string { + if (!isWindowsRemoteHost(host)) { + return `test -e ${shellEscape(lockPath)} && echo LOCKED || echo OPEN` + } + // Why: one prerelease briefly wrote file locks; accept both shapes so those + // hosts remain recoverable after upgrading to directory-plus-owner locks. + return powerShellCommand( + `if (Test-Path -LiteralPath ${powerShellLiteral(lockPath)}) { 'LOCKED' } else { 'OPEN' }` + ) +} + +export function lockAgeSecondsCommand(host: RemoteHostPlatform, lockDir: string): string { + if (!isWindowsRemoteHost(host)) { + return `${posixLockAgeSecondsAssignment(lockDir)} && echo "$age" || echo` + } + return powerShellCommand( + [ + `$item = Get-Item -LiteralPath ${powerShellLiteral(lockDir)} -ErrorAction Stop`, + '$mtime = ([DateTimeOffset]$item.LastWriteTimeUtc).ToUnixTimeSeconds()', + '$now = [DateTimeOffset]::UtcNow.ToUnixTimeSeconds()', + 'Write-Output ($now - $mtime)' + ].join('; ') + ) +} + +export function tryStealInstallLockCommand( + host: RemoteHostPlatform, + lockDir: string, + staleAfterSeconds: number +): string { + if (!isWindowsRemoteHost(host)) { + return posixStealInstallLockCommand(lockDir, staleAfterSeconds) + } + return windowsStealInstallLockCommand(lockDir, staleAfterSeconds) +} + +function posixStealInstallLockCommand(lockDir: string, staleAfterSeconds: number): string { + const escapedLockDir = shellEscape(lockDir) + const escapedStealLockPrefix = shellEscape(`${lockDir}.steal`) + return [ + `${posixLockIdentityAssignment(lockDir, 'lock_key')} && mtime=\${lock_key%%:*} && now=$(date +%s) && age=$((now - mtime)) || age=0;`, + `if [ "\${age:-0}" -le ${staleAfterSeconds} ] 2>/dev/null; then echo BUSY; else`, + `steal_root=${escapedStealLockPrefix};`, + 'steal_generation=0;', + 'steal="$steal_root.$steal_generation";', + 'owns_steal=0;', + 'lock_tombstone=;', + 'while [ "$owns_steal" != 1 ]; do', + 'if mkdir "$steal" 2>/dev/null; then owns_steal=1; break; fi;', + `steal_mtime=$(stat -c %Y "$steal" 2>/dev/null || stat -f %m "$steal" 2>/dev/null) && steal_now=$(date +%s) && steal_age=$((steal_now - steal_mtime)) || break;`, + 'if [ "${steal_age:-0}" -le 120 ] 2>/dev/null; then break; fi;', + 'steal_generation=$((steal_generation + 1));', + 'steal="$steal_root.$steal_generation";', + 'done;', + 'if [ "$owns_steal" = 1 ]; then', + `trap 'rm -rf "$steal_root".* 2>/dev/null || true; rm -rf "$lock_tombstone" 2>/dev/null || true' EXIT;`, + `${posixLockIdentityAssignment(lockDir, 'current_key')} && current_mtime=\${current_key%%:*} && current_now=$(date +%s) && current_age=$((current_now - current_mtime)) || current_age=0;`, + `if [ "$current_key" = "$lock_key" ] && [ "\${current_age:-0}" -gt ${staleAfterSeconds} ] 2>/dev/null; then`, + `lock_tombstone=${escapedLockDir}.tombstone.$$.$(date +%s);`, + `if [ ! -e "$lock_tombstone" ] && mv ${escapedLockDir} "$lock_tombstone" 2>/dev/null; then mkdir ${escapedLockDir} 2>&1 && echo OK || echo BUSY; else echo BUSY; fi;`, + 'else echo BUSY; fi;', + 'else echo BUSY; fi; fi' + ].join(' ') +} + +function windowsStealInstallLockCommand(lockDir: string, staleAfterSeconds: number): string { + return powerShellCommand( + [ + `$lock = ${powerShellLiteral(lockDir)}`, + 'try {', + '$item = Get-Item -LiteralPath $lock -ErrorAction Stop', + '$mtime = ([DateTimeOffset]$item.LastWriteTimeUtc).ToUnixTimeSeconds()', + '$lockIdentity = "${mtime}:$($item.CreationTimeUtc.Ticks)"', + '$now = [DateTimeOffset]::UtcNow.ToUnixTimeSeconds()', + `if (($now - $mtime) -le ${staleAfterSeconds}) { 'BUSY' } else {`, + '$stealRoot = "$lock.steal"', + '$stealGeneration = 0', + '$steal = "$stealRoot.$stealGeneration"', + '$ownsSteal = $false', + '$lockTombstone = $null', + 'try {', + 'while (-not $ownsSteal) {', + 'try {', + '$stealStream = [System.IO.File]::Open($steal, [System.IO.FileMode]::CreateNew, [System.IO.FileAccess]::Write, [System.IO.FileShare]::None)', + '$stealStream.Dispose()', + '$ownsSteal = $true', + 'break', + '} catch {', + 'try {', + '$stealItem = Get-Item -LiteralPath $steal -ErrorAction Stop', + '$stealMtime = ([DateTimeOffset]$stealItem.LastWriteTimeUtc).ToUnixTimeSeconds()', + '$stealAge = [DateTimeOffset]::UtcNow.ToUnixTimeSeconds() - $stealMtime', + 'if ($stealAge -le 120) { break }', + '$stealGeneration++', + '$steal = "$stealRoot.$stealGeneration"', + '} catch { break }', + '}', + '}', + "if (-not $ownsSteal) { 'BUSY' } else {", + '$current = Get-Item -LiteralPath $lock -ErrorAction Stop', + '$currentMtime = ([DateTimeOffset]$current.LastWriteTimeUtc).ToUnixTimeSeconds()', + '$currentIdentity = "${currentMtime}:$($current.CreationTimeUtc.Ticks)"', + '$currentNow = [DateTimeOffset]::UtcNow.ToUnixTimeSeconds()', + `if (($currentIdentity -eq $lockIdentity) -and (($currentNow - $currentMtime) -gt ${staleAfterSeconds})) {`, + '$lockTombstone = "$lock.tombstone.$PID.$([DateTimeOffset]::UtcNow.ToUnixTimeMilliseconds())"', + 'Move-Item -LiteralPath $lock -Destination $lockTombstone -ErrorAction Stop', + '$successorStream = $null', + "try { $null = New-Item -ItemType Directory -Path $lock -ErrorAction Stop; $successorOwner = Join-Path $lock '.owner'; $successorStream = [System.IO.File]::Open($successorOwner, [System.IO.FileMode]::CreateNew, [System.IO.FileAccess]::Write, [System.IO.FileShare]::None); 'OK' } catch { 'BUSY' } finally { if ($null -ne $successorStream) { $successorStream.Dispose() } }", + "} else { 'BUSY' }", + '}', + "} catch { 'BUSY' } finally {", + 'if ($ownsSteal) {', + '$stealParent = Split-Path -Parent $stealRoot', + '$stealLeaf = Split-Path -Leaf $stealRoot', + 'Get-ChildItem -LiteralPath $stealParent -Force -ErrorAction SilentlyContinue | Where-Object { $_.Name.StartsWith($stealLeaf + ".", [StringComparison]::Ordinal) } | Remove-Item -Recurse -Force -ErrorAction SilentlyContinue', + '}', + 'if ($null -ne $lockTombstone) { Remove-Item -LiteralPath $lockTombstone -Recurse -Force -ErrorAction SilentlyContinue }', + '}', + '}', + "} catch { 'BUSY' }" + ].join('; ') + ) +} + +function posixLockAgeSecondsAssignment(lockDir: string): string { + return `${posixLockMtimeSecondsAssignment(lockDir, 'mtime')} && now=$(date +%s) && age=$((now - mtime))` +} + +function posixLockIdentityAssignment(lockDir: string, variableName: string): string { + const escapedLockDir = shellEscape(lockDir) + return `${variableName}=$(stat -c %Y:%i ${escapedLockDir} 2>/dev/null || stat -f %m:%i ${escapedLockDir} 2>/dev/null)` +} + +function posixLockMtimeSecondsAssignment(lockDir: string, variableName: string): string { + const escapedLockDir = shellEscape(lockDir) + return `${variableName}=$(stat -c %Y ${escapedLockDir} 2>/dev/null || stat -f %m ${escapedLockDir} 2>/dev/null)` +} diff --git a/src/main/ssh/ssh-relay-install-lock.ts b/src/main/ssh/ssh-relay-install-lock.ts new file mode 100644 index 00000000000..7cfd561b1ad --- /dev/null +++ b/src/main/ssh/ssh-relay-install-lock.ts @@ -0,0 +1,162 @@ +import type { SshConnection } from './ssh-connection' +import { execCommand } from './ssh-relay-deploy-helpers' +import { RELAY_DEPLOY_TIMEOUT_MS } from './ssh-relay-deploy-timing' +import { isRelayGcClaimed, waitForRelayGcClaimRelease } from './ssh-relay-gc-claim' +import { + acquireInstallLockParentCommand, + lockAgeSecondsCommand, + tryCreateInstallLockCommand, + tryStealInstallLockCommand +} from './ssh-relay-install-lock-commands' +import { + getRemoteHostPlatform, + joinRemotePath, + type RemoteHostPlatform +} from './ssh-remote-platform' +import { removeRemoteTreeCommand } from './ssh-remote-commands' + +export const RELAY_INSTALL_LOCK_NAME = '.install-lock' + +const INSTALL_LOCK_POLL_MS = 1_000 +// Why: a fresh lock can cross the stale threshold during our bounded wait. +// Recheck infrequently so it becomes recoverable without adding an exec per poll. +const INSTALL_LOCK_STALE_RECHECK_MS = 60_000 +// Why: the lock holder can legitimately use the full deploy bound for upload, +// native install/rebuild, probes, and finalization. A concurrent first install +// must not fail earlier; completed-relay repair uses a separate one-shot path. +const INSTALL_LOCK_TIMEOUT_MS = RELAY_DEPLOY_TIMEOUT_MS +// Why: native-deps repair can keep running after the deploy backstop wins its +// Promise.race, so stale takeover must leave room for that bounded work. +export const INSTALL_LOCK_STALE_MS = 20 * 60_000 +export const INSTALL_LOCK_STALE_SECONDS = INSTALL_LOCK_STALE_MS / 1000 +const DEFAULT_REMOTE_HOST = getRemoteHostPlatform('linux-x64') + +function execHostCommand( + conn: SshConnection, + host: RemoteHostPlatform, + command: string, + options?: { signal?: AbortSignal } +): Promise { + return execCommand(conn, command, { + wrapCommand: host.commandDialect !== 'powershell', + signal: options?.signal + }) +} + +export async function isRelayInstallLockStale( + conn: SshConnection, + lockDir: string, + host: RemoteHostPlatform = DEFAULT_REMOTE_HOST +): Promise { + try { + // Why: remote time avoids clock skew between Orca clients making a live + // repair lock look old enough for GC or another installer to recover. + const out = await execHostCommand(conn, host, lockAgeSecondsCommand(host, lockDir)) + const ageSec = Number.parseInt(out.trim(), 10) + return Number.isFinite(ageSec) && ageSec >= 0 && ageSec * 1000 > INSTALL_LOCK_STALE_MS + } catch { + return false + } +} + +/** + * Acquire the per-version install lock via a host-native exclusive create. + * Why: POSIX mkdir and a Windows directory plus atomic owner file each give + * one winner while keeping the marker visible to older Windows Orca clients. + */ +export async function acquireInstallLock( + conn: SshConnection, + remoteRelayDir: string, + host: RemoteHostPlatform = DEFAULT_REMOTE_HOST, + options?: { signal?: AbortSignal } +): Promise { + const lockDir = joinRemotePath(host, remoteRelayDir, RELAY_INSTALL_LOCK_NAME) + + const start = Date.now() + let lastStaleCheckAt = Number.NEGATIVE_INFINITY + while (true) { + // Why: a crashed GC can leave the stable sibling claim behind. The shared + // waiter recovers stale claims instead of polling that orphan forever. + await waitForRelayGcClaimRelease(conn, remoteRelayDir, host, options?.signal) + options?.signal?.throwIfAborted() + await execHostCommand(conn, host, acquireInstallLockParentCommand(host, remoteRelayDir), { + signal: options?.signal + }) + try { + const result = await execHostCommand(conn, host, tryCreateInstallLockCommand(host, lockDir), { + signal: options?.signal + }) + if (result.trim().endsWith('OK')) { + // Why: GC may claim the sibling path between our first probe and lock + // creation. Recheck while holding the in-tree lock; one side backs off. + const claimedAfterAcquire = await isRelayGcClaimed( + conn, + remoteRelayDir, + host, + options?.signal + ).catch(() => true) + if (!claimedAfterAcquire && !options?.signal?.aborted) { + return + } + await execHostCommand(conn, host, removeRemoteTreeCommand(host, lockDir)).catch(() => {}) + options?.signal?.throwIfAborted() + } + } catch { + options?.signal?.throwIfAborted() + // A failed mkdir is lock contention; keep the connection-specific error + // out of the user path until the bounded wait expires. + } + if (Date.now() - lastStaleCheckAt >= INSTALL_LOCK_STALE_RECHECK_MS) { + lastStaleCheckAt = Date.now() + // Why: recover an already-stale lock immediately, then keep checking in + // case a fresh holder crosses the stale threshold while we are waiting. + const steal = await execHostCommand( + conn, + host, + tryStealInstallLockCommand(host, lockDir, INSTALL_LOCK_STALE_SECONDS), + { signal: options?.signal } + ).catch(() => 'BUSY') + options?.signal?.throwIfAborted() + if (steal.trim().endsWith('OK')) { + console.warn(`[ssh-relay] Stealing stale install lock at ${lockDir}`) + const claimedAfterSteal = await isRelayGcClaimed( + conn, + remoteRelayDir, + host, + options?.signal + ).catch(() => true) + if (!claimedAfterSteal && !options?.signal?.aborted) { + return + } + await execHostCommand(conn, host, removeRemoteTreeCommand(host, lockDir)).catch(() => {}) + options?.signal?.throwIfAborted() + } + } + if (Date.now() - start >= INSTALL_LOCK_TIMEOUT_MS) { + throw new Error( + `Could not acquire relay install lock at ${lockDir} after ${ + INSTALL_LOCK_TIMEOUT_MS / 1000 + }s; another install is still in progress.` + ) + } + await waitForInstallLockPoll(options?.signal) + } +} + +function waitForInstallLockPoll(signal?: AbortSignal): Promise { + return new Promise((resolve, reject) => { + const handleAbort = (): void => { + clearTimeout(timeout) + signal?.removeEventListener('abort', handleAbort) + reject(signal?.reason) + } + const timeout = setTimeout(() => { + signal?.removeEventListener('abort', handleAbort) + resolve() + }, INSTALL_LOCK_POLL_MS) + signal?.addEventListener('abort', handleAbort, { once: true }) + if (signal?.aborted) { + handleAbort() + } + }) +} diff --git a/src/main/ssh/ssh-relay-native-deps-install.test.ts b/src/main/ssh/ssh-relay-native-deps-install.test.ts index 4d2f92eb947..f38a2b1065b 100644 --- a/src/main/ssh/ssh-relay-native-deps-install.test.ts +++ b/src/main/ssh/ssh-relay-native-deps-install.test.ts @@ -1,6 +1,6 @@ // Why: regression coverage for the install-probe contract. The original // "node-pty is not available" bug shipped because every layer that should -// have caught it (chained shell, swallowing catch, dir-only probe) was +// have caught it (chained shell, swallowing catch, resolve-only probe) was // silent. Tests below pin the parts that, individually, would have caught // it. @@ -30,6 +30,9 @@ vi.mock('./ssh-relay-deploy-helpers', () => ({ onData: vi.fn(), onClose: vi.fn() }), + isUnconfirmedSshCommandTermination: (error: unknown) => + error instanceof Error && + (error as Error & { sshChannelCloseConfirmed?: boolean }).sshChannelCloseConfirmed === false, execCommand: vi.fn() })) @@ -41,26 +44,41 @@ vi.mock('./ssh-relay-versioned-install', () => ({ readLocalFullVersion: vi.fn().mockReturnValue('0.1.0+testhash'), computeRemoteRelayDir: (home: string, v: string) => `${home}/.orca-remote/relay-${v}`, isRelayAlreadyInstalled: vi.fn().mockResolvedValue(false), - acquireInstallLock: vi.fn().mockResolvedValue(undefined), finalizeInstall: vi.fn().mockResolvedValue(undefined), abandonInstall: vi.fn().mockResolvedValue(undefined), gcOldRelayVersions: vi.fn().mockResolvedValue(undefined) })) +vi.mock('./ssh-relay-install-lock', () => ({ + acquireInstallLock: vi.fn().mockResolvedValue(undefined) +})) + +vi.mock('./ssh-relay-repair-lock', () => ({ + tryAcquireRelayRepairLock: vi.fn().mockResolvedValue('acquired') +})) + +vi.mock('./ssh-relay-gc-claim', () => ({ + releaseRelayGcClaimWithRetry: vi.fn().mockResolvedValue('released'), + tryAcquireRelayGcClaim: vi.fn().mockResolvedValue('launch-token'), + waitForRelayGcClaimRelease: vi.fn().mockResolvedValue(undefined) +})) + vi.mock('./ssh-connection-utils', () => ({ shellEscape: (s: string) => `'${s}'` })) import { deployAndLaunchRelay } from './ssh-relay-deploy' -import { execCommand } from './ssh-relay-deploy-helpers' +import { execCommand, uploadDirectory } from './ssh-relay-deploy-helpers' +import { RELAY_DEPLOY_TIMEOUT_MS } from './ssh-relay-deploy-timing' import { parseUnameToRelayPlatform } from './relay-protocol' import { resolveRemoteNodePath } from './ssh-remote-node-resolution' import { - acquireInstallLock, abandonInstall, finalizeInstall, isRelayAlreadyInstalled } from './ssh-relay-versioned-install' +import { acquireInstallLock } from './ssh-relay-install-lock' +import { tryAcquireRelayRepairLock } from './ssh-relay-repair-lock' import type { SshConnection } from './ssh-connection' type SftpWriteCapture = { @@ -129,6 +147,7 @@ function decodePowerShellCommand(command: string): string | null { // 7: probe (cd && node -e require) // [8: cat stderr — only when probe stdout is MISSING (graceful path)] // 8 or 9: rm probe-stderr (best-effort cleanup; runs whenever probe resolved) +// [next: npm rebuild → chmod → second probe when the first probe is MISSING] // next: socket DEAD next: socket READY // // When the probe rejects (SSH channel close or cd-failure when the install @@ -143,6 +162,9 @@ function makeExecResponses(opts: { // Override probe stdout for shell-noise pressure tests. If set, replaces // the load-test stdout entirely (useful for testing pollution prefixes). probeStdoutOverride?: string + // Result after the automatic rebuild. Defaults to missing so legacy tests + // continue to exercise the final degraded-mode warning. + repairProbe?: 'ok' | 'missing' // Raw stdout for the build-toolchain probe that runs in installNativeDeps' // catch when `npm install` rejects on Linux. Defaults to a fully-present // toolchain so the original npm error propagates unchanged. @@ -187,6 +209,16 @@ function makeExecResponses(opts: { slots.push('') // cat stderr (graceful failure path captures detail) } slots.push('') // rm -f stderr (best-effort cleanup) + if (!probeOk) { + slots.push('') // npm rebuild with lifecycle scripts explicitly enabled + slots.push('') // chmod prebuilds after rebuild + const repairProbe = opts.repairProbe === 'ok' ? 'ORCA-NPTY-PROBE-OK\n' : 'MISSING\n' + slots.push(repairProbe) + if (!repairProbe.includes('ORCA-NPTY-PROBE-OK')) { + slots.push('') // cat stderr after unsuccessful rebuild + } + slots.push('') // rm -f stderr after rebuild probe + } } slots.push('DEAD', 'READY') return slots @@ -207,6 +239,7 @@ describe('installNativeDeps (via deployAndLaunchRelay)', () => { // a leaked response. clearAllMocks doesn't drop the queue (it only clears // .mock.calls), so we explicitly mockReset. vi.mocked(execCommand).mockReset() + vi.mocked(uploadDirectory).mockResolvedValue(undefined) sftpCapture.paths.length = 0 for (const k of Object.keys(sftpCapture.contents)) { delete sftpCapture.contents[k] @@ -256,12 +289,17 @@ describe('installNativeDeps (via deployAndLaunchRelay)', () => { // break `require('node-pty')`. expect(parsed.type).toBe('commonjs') expect(parsed.dependencies).toEqual({ '@parcel/watcher': '2.5.6', 'node-pty': '1.1.0' }) + expect(parsed.allowScripts).toEqual({ + '@parcel/watcher@2.5.6': true, + 'node-pty@1.1.0': true + }) const execCalls = vi.mocked(execCommand).mock.calls.map(([, c]) => c) const npmInstallIdx = execCalls.findIndex( (c) => c.includes('npm install') && c.includes('node-pty') && c.includes('@parcel/watcher') ) expect(npmInstallIdx).toBeGreaterThanOrEqual(0) + expect(execCalls[npmInstallIdx]).toContain('--ignore-scripts=false') // Pin actual ordering: number of execCommand calls observed at the moment // ws.end() ran for package.json must be < the index of `npm install`. // Catches a future refactor that fires SFTP-write and npm install via @@ -376,7 +414,96 @@ describe('installNativeDeps (via deployAndLaunchRelay)', () => { expect(warnMessages.some((m) => m.includes('[ssh-relay][NPTY-MISSING]'))).toBe(true) expect(vi.mocked(finalizeInstall)).toHaveBeenCalledTimes(1) - expect(vi.mocked(abandonInstall)).not.toHaveBeenCalled() + expect(vi.mocked(abandonInstall)).toHaveBeenCalledTimes(1) + }) + + it('rebuilds unloadable native deps and recovers before first relay launch', async () => { + const conn = makeMockConnection(sftpCapture) + feed(makeExecResponses({ npmInstall: 'ok', probe: 'missing', repairProbe: 'ok' })) + + await deployAndLaunchRelay(conn) + + const execCalls = vi.mocked(execCommand).mock.calls.map(([, c]) => c) + const failedProbeIdx = execCalls.findIndex((c) => c.includes('require("node-pty")')) + const rebuildIdx = execCalls.findIndex((c) => c.includes('npm rebuild')) + const repairedProbeIdx = execCalls.findIndex( + (c, index) => index > rebuildIdx && c.includes('require("node-pty")') + ) + expect(rebuildIdx).toBeGreaterThan(failedProbeIdx) + expect(execCalls[rebuildIdx]).toContain('--ignore-scripts=false') + expect(repairedProbeIdx).toBeGreaterThan(rebuildIdx) + + const warnMessages = warnSpy.mock.calls.map((args) => String(args[0] ?? '')) + expect(warnMessages.some((m) => m.includes('[ssh-relay][NPTY-MISSING]'))).toBe(false) + expect(vi.mocked(finalizeInstall)).toHaveBeenCalledTimes(1) + }) + + it('propagates an SSH-channel failure from the post-rebuild re-probe', async () => { + // Why: the rebuild itself degrades gracefully, but the verification probe + // after it must still surface transport death — conflating a dead channel + // with "native deps missing" would finalize a half-repaired install. + const conn = makeMockConnection(sftpCapture) + feed([ + '__ORCA_REMOTE_PLATFORM__ Linux x86_64', // uname + '/home/u', // $HOME + '', // mkdir remoteDir (uploadRelay) + '', // chmod +x node + '', // npm install native deps + '', // chmod prebuilds + 'MISSING\n', // first probe: require() fails + '', // cat probe stderr + '', // rm probe stderr + '', // npm rebuild native deps + '', // chmod prebuilds after rebuild + { reject: 'SSH channel closed during native deps re-probe' } // re-probe rejects + ]) + + await expect(deployAndLaunchRelay(conn)).rejects.toThrow(/SSH channel closed/) + + // Rebuild failure is swallowed; a re-probe transport failure must not be. + const warnMessages = warnSpy.mock.calls.map((args) => String(args[0] ?? '')) + expect(warnMessages.some((m) => m.includes('[ssh-relay][NPTY-MISSING]'))).toBe(false) + expect(vi.mocked(finalizeInstall)).not.toHaveBeenCalled() + expect(vi.mocked(abandonInstall)).toHaveBeenCalledTimes(1) + }) + + it('aborts an in-progress native install and releases its lock at deploy timeout', async () => { + vi.useFakeTimers() + try { + const conn = makeMockConnection(sftpCapture) + feed([ + '__ORCA_REMOTE_PLATFORM__ Linux x86_64', + '/home/u', + '', // mkdir remoteDir + '' // chmod +x node + ]) + let installSignal: AbortSignal | undefined + vi.mocked(execCommand).mockImplementationOnce((_conn, command, options) => { + expect(command).toContain('npm install') + installSignal = options?.signal + return new Promise((_resolve, reject) => { + installSignal?.addEventListener('abort', () => reject(installSignal?.reason), { + once: true + }) + }) + }) + + const promise = deployAndLaunchRelay(conn).catch((err: Error) => err) + await vi.waitFor(() => expect(installSignal).toBeDefined()) + + await vi.advanceTimersByTimeAsync(RELAY_DEPLOY_TIMEOUT_MS) + + const result = await promise + expect(result).toBeInstanceOf(Error) + expect((result as Error).message).toBe( + `Relay deployment timed out after ${RELAY_DEPLOY_TIMEOUT_MS / 1000}s` + ) + expect(installSignal?.aborted).toBe(true) + expect(vi.mocked(abandonInstall)).toHaveBeenCalledTimes(1) + expect(vi.mocked(finalizeInstall)).not.toHaveBeenCalled() + } finally { + vi.useRealTimers() + } }) it('lets a probe SSH-channel failure bubble up rather than silently mapping to MISSING', async () => { @@ -479,9 +606,9 @@ describe('installNativeDeps (via deployAndLaunchRelay)', () => { expect(chmodPrebuildsIdx).toBeGreaterThan(npmIdx) expect(probeIdx).toBeGreaterThan(chmodPrebuildsIdx) - // Happy path: finalize exactly once, abandon never. + // Hold the install lock through launch, then release it exactly once. expect(vi.mocked(finalizeInstall)).toHaveBeenCalledTimes(1) - expect(vi.mocked(abandonInstall)).not.toHaveBeenCalled() + expect(vi.mocked(abandonInstall)).toHaveBeenCalledTimes(1) }) it('matches the sentinel even with bashrc/MOTD noise prefixed to probe stdout', async () => { @@ -522,7 +649,7 @@ describe('installNativeDeps (via deployAndLaunchRelay)', () => { const warnMessages = warnSpy.mock.calls.map((args) => String(args[0] ?? '')) expect(warnMessages.some((m) => m.includes('[ssh-relay][NPTY-MISSING]'))).toBe(true) expect(vi.mocked(finalizeInstall)).toHaveBeenCalledTimes(1) - expect(vi.mocked(abandonInstall)).not.toHaveBeenCalled() + expect(vi.mocked(abandonInstall)).toHaveBeenCalledTimes(1) }) it('keeps Windows node-pty probe failures non-fatal by checking LASTEXITCODE', async () => { @@ -535,7 +662,8 @@ describe('installNativeDeps (via deployAndLaunchRelay)', () => { '', // mkdir remoteDir '', // npm install native deps 'MISSING\n', // native process exit normalized by PowerShell command - '', // remove probe stderr file + '', // npm rebuild native deps + 'MISSING\n', // rebuilt native process still cannot load '', // no persisted active pipe marker 'WAITING', '', // WMI relay launch @@ -554,11 +682,29 @@ describe('installNativeDeps (via deployAndLaunchRelay)', () => { const probeScript = decodePowerShellCommand(probeCommand) ?? '' expect(probeScript).toContain('$LASTEXITCODE -ne 0') expect(probeScript).toContain("'MISSING'") + expect(probeScript).toContain('loadNativeModule') + + const npmScripts = vi + .mocked(execCommand) + .mock.calls.map(([, command]) => decodePowerShellCommand(command) ?? '') + .filter((script) => script.includes('npm install') || script.includes('npm rebuild')) + expect(npmScripts).toHaveLength(2) + expect(npmScripts.every((script) => script.includes('--ignore-scripts=false'))).toBe(true) + expect( + npmScripts.every((script) => + script.includes('if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }') + ) + ).toBe(true) + expect( + vi + .mocked(execCommand) + .mock.calls.some(([, command]) => command.includes('.npty-probe.stderr')) + ).toBe(false) const warnMessages = warnSpy.mock.calls.map((args) => String(args[0] ?? '')) expect(warnMessages.some((m) => m.includes('[ssh-relay][NPTY-MISSING]'))).toBe(true) expect(vi.mocked(finalizeInstall)).toHaveBeenCalledTimes(1) - expect(vi.mocked(abandonInstall)).not.toHaveBeenCalled() + expect(vi.mocked(abandonInstall)).toHaveBeenCalledTimes(1) }) it('includes the platform tuple in NPTY-MISSING and native install failure logs', async () => { @@ -607,8 +753,8 @@ describe('installNativeDeps (via deployAndLaunchRelay)', () => { feed([ '__ORCA_REMOTE_PLATFORM__ Linux x86_64', '/home/u', - 'MISSING', // first native-deps probe before lock - 'MISSING', // re-probe after lock + 'ORCA-NATIVE-DEPS-MISSING:@parcel/watcher\nMISSING', // first probe before lock + 'ORCA-NATIVE-DEPS-MISSING:@parcel/watcher\nMISSING', // re-probe after lock '', // npm install native deps '', // chmod prebuilds 'ORCA-NPTY-PROBE-OK\n', @@ -619,7 +765,10 @@ describe('installNativeDeps (via deployAndLaunchRelay)', () => { await deployAndLaunchRelay(conn) - expect(vi.mocked(acquireInstallLock)).toHaveBeenCalledTimes(1) + expect(vi.mocked(tryAcquireRelayRepairLock)).toHaveBeenCalledTimes(1) + expect(vi.mocked(tryAcquireRelayRepairLock).mock.calls[0]?.[3]?.signal).toBeInstanceOf( + AbortSignal + ) expect(vi.mocked(finalizeInstall)).toHaveBeenCalledTimes(1) const execCalls = vi.mocked(execCommand).mock.calls.map(([, c]) => c) expect( @@ -627,6 +776,228 @@ describe('installNativeDeps (via deployAndLaunchRelay)', () => { (c) => c.includes('npm install') && c.includes('node-pty') && c.includes('@parcel/watcher') ) ).toBe(true) + const installCommand = execCalls.find((c) => c.includes('npm install')) ?? '' + expect(installCommand).toContain('node_modules/@parcel/watcher') + expect(installCommand).toContain("-name 'watcher-*'") + expect(installCommand).not.toContain("rm -rf 'node_modules/node-pty'") + }) + + it('launches an already-installed relay in degraded mode when repair throws', async () => { + // Why: a repair failure on a completed dir (e.g. offline/proxy-locked npm) + // must not block the connection — the relay still serves fs/git/preflight. + // Pre-fix this rethrew and aborted the whole deploy. The next reconnect + // retries, so a transient failure self-heals. + vi.mocked(isRelayAlreadyInstalled).mockResolvedValue(true) + const conn = makeMockConnection(sftpCapture) + feed([ + '__ORCA_REMOTE_PLATFORM__ Linux x86_64', + '/home/u', + 'MISSING', // health probe: require() fails + 'MISSING', // re-probe after lock + { reject: 'npm ERR! network ETIMEDOUT' }, // npm install fails (offline) + 'DEAD', + 'READY' + ]) + + // Deploy must resolve (degraded), not reject. + await deployAndLaunchRelay(conn) + + expect(vi.mocked(finalizeInstall)).not.toHaveBeenCalled() + expect(vi.mocked(abandonInstall)).toHaveBeenCalledTimes(1) + const warnMessages = warnSpy.mock.calls.map((args) => String(args[0] ?? '')) + expect(warnMessages.some((m) => m.includes('launching degraded'))).toBe(true) + }) + + it('retains the repair lock when remote command termination is unconfirmed', async () => { + vi.mocked(isRelayAlreadyInstalled).mockResolvedValue(true) + const conn = makeMockConnection(sftpCapture) + vi.mocked(execCommand) + .mockResolvedValueOnce('__ORCA_REMOTE_PLATFORM__ Linux x86_64') + .mockResolvedValueOnce('/home/u') + .mockResolvedValueOnce('MISSING') + .mockResolvedValueOnce('MISSING') + .mockRejectedValueOnce( + Object.assign(new Error('npm termination was not confirmed'), { + sshChannelCloseConfirmed: false + }) + ) + .mockResolvedValueOnce('DEAD') + .mockResolvedValueOnce('READY') + + await deployAndLaunchRelay(conn) + + expect(vi.mocked(abandonInstall)).not.toHaveBeenCalled() + const warnMessages = warnSpy.mock.calls.map((args) => String(args[0] ?? '')) + expect(warnMessages.some((message) => message.includes('launching degraded'))).toBe(true) + }) + + it('retains the first-install lock when an aborted npm install has unconfirmed teardown', async () => { + vi.useFakeTimers() + try { + const conn = makeMockConnection(sftpCapture) + feed([ + '__ORCA_REMOTE_PLATFORM__ Linux x86_64', + '/home/u', + '', // mkdir remoteDir + '' // chmod +x node + ]) + let installSignal: AbortSignal | undefined + vi.mocked(execCommand).mockImplementationOnce((_conn, command, options) => { + expect(command).toContain('npm install') + installSignal = options?.signal + return new Promise((_resolve, reject) => { + installSignal?.addEventListener( + 'abort', + () => { + // Mirrors execCommand's bounded close grace when ssh2 never + // confirms that the remote npm process stopped. + setTimeout( + () => + reject( + Object.assign(new Error('npm teardown remained unconfirmed'), { + sshChannelCloseConfirmed: false + }) + ), + 5_000 + ) + }, + { once: true } + ) + }) + }) + + const deploy = deployAndLaunchRelay(conn).catch((err: Error) => err) + await vi.waitFor(() => expect(installSignal).toBeDefined()) + await vi.advanceTimersByTimeAsync(RELAY_DEPLOY_TIMEOUT_MS) + const result = await deploy + expect(result).toBeInstanceOf(Error) + expect((result as Error).message).toContain('Relay deployment timed out') + await vi.advanceTimersByTimeAsync(5_000) + + expect(vi.mocked(abandonInstall)).not.toHaveBeenCalled() + expect(vi.mocked(finalizeInstall)).not.toHaveBeenCalled() + } finally { + vi.useRealTimers() + } + }) + + it('does not finalize or release a first-install lock after unconfirmed rebuild teardown', async () => { + const conn = makeMockConnection(sftpCapture) + vi.mocked(execCommand) + .mockResolvedValueOnce('__ORCA_REMOTE_PLATFORM__ Linux x86_64') + .mockResolvedValueOnce('/home/u') + .mockResolvedValueOnce('') + .mockResolvedValueOnce('') + .mockResolvedValueOnce('') + .mockResolvedValueOnce('') + .mockResolvedValueOnce('MISSING') + .mockResolvedValueOnce('rebuild diagnostics') + .mockResolvedValueOnce('') + .mockRejectedValueOnce( + Object.assign(new Error('rebuild termination was not confirmed'), { + sshChannelCloseConfirmed: false + }) + ) + + await expect(deployAndLaunchRelay(conn)).rejects.toThrow( + 'rebuild termination was not confirmed' + ) + expect(vi.mocked(finalizeInstall)).not.toHaveBeenCalled() + expect(vi.mocked(abandonInstall)).not.toHaveBeenCalled() + }) + + it('retains the first-install lock when an aborted rebuild has unconfirmed teardown', async () => { + vi.useFakeTimers() + try { + const conn = makeMockConnection(sftpCapture) + feed([ + '__ORCA_REMOTE_PLATFORM__ Linux x86_64', + '/home/u', + '', // mkdir remoteDir + '', // chmod +x node + '', // npm install + '', // chmod prebuilds + 'MISSING', + 'rebuild diagnostics', + '' // remove probe diagnostics + ]) + let rebuildSignal: AbortSignal | undefined + vi.mocked(execCommand).mockImplementationOnce((_conn, command, options) => { + expect(command).toContain('npm rebuild') + rebuildSignal = options?.signal + return new Promise((_resolve, reject) => { + rebuildSignal?.addEventListener( + 'abort', + () => { + setTimeout( + () => + reject( + Object.assign(new Error('rebuild teardown remained unconfirmed'), { + sshChannelCloseConfirmed: false + }) + ), + 5_000 + ) + }, + { once: true } + ) + }) + }) + + const deploy = deployAndLaunchRelay(conn).catch((err: Error) => err) + await vi.waitFor(() => expect(rebuildSignal).toBeDefined()) + await vi.advanceTimersByTimeAsync(RELAY_DEPLOY_TIMEOUT_MS) + const result = await deploy + expect(result).toBeInstanceOf(Error) + expect((result as Error).message).toContain('Relay deployment timed out') + await vi.advanceTimersByTimeAsync(5_000) + + expect(vi.mocked(abandonInstall)).not.toHaveBeenCalled() + expect(vi.mocked(finalizeInstall)).not.toHaveBeenCalled() + } finally { + vi.useRealTimers() + } + }) + + it.each(['busy', 'error'] as const)('launches degraded when lock is %s', async (lockResult) => { + // Why: lock contention/wedge must not block a completed relay from launching + // in degraded mode — repair is best-effort and we hold no lock to release. + vi.mocked(isRelayAlreadyInstalled).mockResolvedValue(true) + vi.mocked(tryAcquireRelayRepairLock).mockResolvedValueOnce(lockResult) + const conn = makeMockConnection(sftpCapture) + feed(['__ORCA_REMOTE_PLATFORM__ Linux x86_64', '/home/u', 'MISSING', 'DEAD', 'READY']) + + await deployAndLaunchRelay(conn) + + expect(vi.mocked(finalizeInstall)).not.toHaveBeenCalled() + expect(vi.mocked(abandonInstall)).not.toHaveBeenCalled() + const execCalls = vi.mocked(execCommand).mock.calls.map(([, c]) => c) + expect(execCalls.some((c) => c.includes('npm install'))).toBe(false) + const warnMessages = warnSpy.mock.calls.map((args) => String(args[0] ?? '')) + expect(warnMessages.some((m) => m.includes(`repair lock is ${lockResult}`))).toBe(true) + }) + + it('loads native bindings when checking whether a completed relay needs repair', async () => { + vi.mocked(isRelayAlreadyInstalled).mockResolvedValue(true) + const conn = makeMockConnection(sftpCapture) + feed([ + '__ORCA_REMOTE_PLATFORM__ Linux x86_64', + '/home/u', + 'ORCA-NATIVE-DEPS-OK', + 'DEAD', + 'READY' + ]) + + await deployAndLaunchRelay(conn) + + const healthProbe = vi + .mocked(execCommand) + .mock.calls.map(([, c]) => c) + .find((c) => c.includes('ORCA-NATIVE-DEPS-OK')) + expect(healthProbe).toContain('require("node-pty")') + expect(healthProbe).toContain('loadNativeModule') + expect(healthProbe).toContain('require("@parcel/watcher")') + expect(healthProbe).not.toContain('require.resolve') }) it('does not mutate an existing relay dir when required native deps are present', async () => { @@ -643,7 +1014,9 @@ describe('installNativeDeps (via deployAndLaunchRelay)', () => { await deployAndLaunchRelay(conn) expect(vi.mocked(acquireInstallLock)).not.toHaveBeenCalled() + expect(vi.mocked(tryAcquireRelayRepairLock)).toHaveBeenCalledTimes(1) expect(vi.mocked(finalizeInstall)).not.toHaveBeenCalled() + expect(vi.mocked(abandonInstall)).toHaveBeenCalledTimes(1) const execCalls = vi.mocked(execCommand).mock.calls.map(([, c]) => c) expect(execCalls.some((c) => c.includes('npm install'))).toBe(false) }) diff --git a/src/main/ssh/ssh-relay-repair-lock.ts b/src/main/ssh/ssh-relay-repair-lock.ts new file mode 100644 index 00000000000..01d58e390e1 --- /dev/null +++ b/src/main/ssh/ssh-relay-repair-lock.ts @@ -0,0 +1,153 @@ +import type { SshConnection } from './ssh-connection' +import { execCommand } from './ssh-relay-deploy-helpers' +import { + acquireInstallLockParentCommand, + lockAgeSecondsCommand, + probeInstallLockExistsCommand, + tryCreateInstallLockCommand, + tryStealInstallLockCommand +} from './ssh-relay-install-lock-commands' +import { isRelayGcClaimed } from './ssh-relay-gc-claim' +import { + getRemoteHostPlatform, + isWindowsRemoteHost, + joinRemotePath, + type RemoteHostPlatform +} from './ssh-remote-platform' +import { INSTALL_LOCK_STALE_SECONDS, RELAY_INSTALL_LOCK_NAME } from './ssh-relay-install-lock' +import { removeRemoteTreeCommand } from './ssh-remote-commands' + +const DEFAULT_REMOTE_HOST = getRemoteHostPlatform('linux-x64') + +export type RelayRepairLockResult = 'acquired' | 'busy' | 'gc' | 'error' + +function execHostCommand( + conn: SshConnection, + host: RemoteHostPlatform, + command: string, + signal?: AbortSignal +): Promise { + return execCommand(conn, command, { wrapCommand: !isWindowsRemoteHost(host), signal }) +} + +/** + * Try once to acquire the install lock for best-effort repair work. + * + * Why: a completed relay can launch in degraded mode, so repair must not wait + * behind another installer. An already-stale lock is still recovered now. + */ +export async function tryAcquireRelayRepairLock( + conn: SshConnection, + remoteRelayDir: string, + host: RemoteHostPlatform = DEFAULT_REMOTE_HOST, + options?: { signal?: AbortSignal } +): Promise { + const lockDir = joinRemotePath(host, remoteRelayDir, RELAY_INSTALL_LOCK_NAME) + try { + const gcClaimedBeforeAcquire = await isRelayGcClaimed( + conn, + remoteRelayDir, + host, + options?.signal + ).catch(() => undefined) + options?.signal?.throwIfAborted() + if (gcClaimedBeforeAcquire === true) { + return 'gc' + } + if (gcClaimedBeforeAcquire !== false) { + return 'error' + } + await execHostCommand( + conn, + host, + acquireInstallLockParentCommand(host, remoteRelayDir), + options?.signal + ) + const firstAttempt = await execHostCommand( + conn, + host, + tryCreateInstallLockCommand(host, lockDir), + options?.signal + ) + if (firstAttempt.trim().endsWith('OK')) { + return finishRepairLockAcquire(conn, remoteRelayDir, lockDir, host, options?.signal) + } + const steal = await execHostCommand( + conn, + host, + tryStealInstallLockCommand(host, lockDir, INSTALL_LOCK_STALE_SECONDS), + options?.signal + ) + if (steal.trim().endsWith('OK')) { + console.warn(`[ssh-relay] Stealing stale install lock at ${lockDir}`) + return finishRepairLockAcquire(conn, remoteRelayDir, lockDir, host, options?.signal) + } + return classifyRepairLockContention(conn, remoteRelayDir, lockDir, host, options?.signal) + } catch { + options?.signal?.throwIfAborted() + return classifyRepairLockContention(conn, remoteRelayDir, lockDir, host, options?.signal) + } +} + +async function classifyRepairLockContention( + conn: SshConnection, + remoteRelayDir: string, + lockDir: string, + host: RemoteHostPlatform, + signal?: AbortSignal +): Promise { + const gcClaimed = await isRelayGcClaimed(conn, remoteRelayDir, host, signal).catch( + () => undefined + ) + signal?.throwIfAborted() + if (gcClaimed === true) { + return 'gc' + } + if (gcClaimed !== false) { + return 'error' + } + + const lockProbe = await execHostCommand( + conn, + host, + probeInstallLockExistsCommand(host, lockDir), + signal + ).catch(() => '') + signal?.throwIfAborted() + if (lockProbe.trim() !== 'LOCKED') { + return 'error' + } + const ageOutput = await execHostCommand( + conn, + host, + lockAgeSecondsCommand(host, lockDir), + signal + ).catch(() => '') + signal?.throwIfAborted() + const ageSeconds = Number.parseInt(ageOutput.trim(), 10) + // Why: GC may remove stale locks, so only a positively observed fresh lock + // proves that another launch/repair owner is fencing this directory. + return Number.isFinite(ageSeconds) && ageSeconds >= 0 && ageSeconds <= INSTALL_LOCK_STALE_SECONDS + ? 'busy' + : 'error' +} + +async function finishRepairLockAcquire( + conn: SshConnection, + remoteRelayDir: string, + lockDir: string, + host: RemoteHostPlatform, + signal?: AbortSignal +): Promise { + const gcClaimed = await isRelayGcClaimed(conn, remoteRelayDir, host, signal).catch( + () => undefined + ) + if (gcClaimed === false && !signal?.aborted) { + return 'acquired' + } + // Why: GC may win its stable sibling claim while this command creates the + // in-tree lock. Back out before npm can mutate a directory being renamed. + await execHostCommand(conn, host, removeRemoteTreeCommand(host, lockDir)).catch(() => {}) + signal?.throwIfAborted() + return gcClaimed ? 'gc' : 'error' +} diff --git a/src/main/ssh/ssh-relay-versioned-install.test.ts b/src/main/ssh/ssh-relay-versioned-install.test.ts index 244ae80a48c..9bd3f73c8df 100644 --- a/src/main/ssh/ssh-relay-versioned-install.test.ts +++ b/src/main/ssh/ssh-relay-versioned-install.test.ts @@ -18,11 +18,19 @@ import { readLocalFullVersion, computeRemoteRelayDir, isRelayAlreadyInstalled, - acquireInstallLock, finalizeInstall, abandonInstall, gcOldRelayVersions } from './ssh-relay-versioned-install' +import { acquireInstallLock } from './ssh-relay-install-lock' +import { tryAcquireRelayRepairLock } from './ssh-relay-repair-lock' +import { + isRelayGcClaimed, + relayGcClaimPath, + releaseRelayGcClaim, + tryAcquireRelayGcClaim, + waitForRelayGcClaimRelease +} from './ssh-relay-gc-claim' import { execCommand } from './ssh-relay-deploy-helpers' import { getRemoteHostPlatform } from './ssh-remote-platform' import type { SshConnection } from './ssh-connection' @@ -88,6 +96,18 @@ describe('isRelayAlreadyInstalled', () => { expect(await isRelayAlreadyInstalled(conn, '/r')).toBe(false) }) + it('does not convert an aborted install probe into a missing install', async () => { + const controller = new AbortController() + mockExec.mockImplementationOnce(async () => { + controller.abort() + throw Object.assign(new Error('cancelled'), { name: 'AbortError' }) + }) + + await expect( + isRelayAlreadyInstalled(conn, '/r', undefined, { signal: controller.signal }) + ).rejects.toMatchObject({ name: 'AbortError' }) + }) + it('keeps default probe failures as not installed for SSH session-limit-shaped errors', async () => { const sessionLimitError = Object.assign(new Error('(SSH) Channel open failure: open failed'), { reason: 4 @@ -124,26 +144,130 @@ describe('acquireInstallLock', () => { }) it('returns when mkdir reports OK', async () => { - // 1st call: mkdir -p remoteRelayDir - // 2nd call: mkdir lockDir → OK - mockExec.mockResolvedValueOnce('').mockResolvedValueOnce('OK') + mockExec + .mockResolvedValueOnce('OPEN') + .mockResolvedValueOnce('') + .mockResolvedValueOnce('OK') + .mockResolvedValueOnce('OPEN') await acquireInstallLock(conn, '/r') - expect(mockExec).toHaveBeenCalledTimes(2) + expect(mockExec).toHaveBeenCalledTimes(4) + }) + + it('recovers a stale sibling GC claim before first-install lock acquisition', async () => { + mockExec + .mockResolvedValueOnce('LOCKED') + .mockResolvedValueOnce('OK') + .mockResolvedValueOnce('') + .mockResolvedValueOnce('RELEASED') + .mockResolvedValueOnce('') + .mockResolvedValueOnce('OK') + .mockResolvedValueOnce('OPEN') + + await expect(acquireInstallLock(conn, '/r')).resolves.toBeUndefined() + + const commands = mockExec.mock.calls.map(([, command]) => command) + expect(commands[1]).toContain('lock_tombstone') + expect(commands[4]).toBe("mkdir -p '/r'") + }) + + it('returns immediately without deleting a live repair lock', async () => { + // Why: repair can run npm install plus rebuild under the same lock; a + // second reconnect must launch degraded rather than corrupt node_modules. + mockExec + .mockResolvedValueOnce('OPEN') + .mockResolvedValueOnce('') + .mockResolvedValueOnce('BUSY') + .mockResolvedValueOnce('BUSY') + .mockResolvedValueOnce('OPEN') + .mockResolvedValueOnce('LOCKED') + .mockResolvedValueOnce('0') + + await expect(tryAcquireRelayRepairLock(conn, '/r')).resolves.toBe('busy') + + const commands = mockExec.mock.calls.map(([, command]) => command) + expect(commands.some((command) => command.includes('lock_tombstone'))).toBe(true) + expect(commands.filter((command) => command.startsWith('rm -rf'))).toHaveLength(0) + }) + + it('does not report stale or indeterminate contention as a launch fence', async () => { + mockExec + .mockResolvedValueOnce('OPEN') + .mockResolvedValueOnce('') + .mockResolvedValueOnce('BUSY') + .mockResolvedValueOnce('BUSY') + .mockResolvedValueOnce('OPEN') + .mockResolvedValueOnce('LOCKED') + .mockResolvedValueOnce(`${21 * 60}`) + + await expect(tryAcquireRelayRepairLock(conn, '/r')).resolves.toBe('error') + + mockExec.mockReset().mockRejectedValueOnce(new Error('claim probe failed')) + await expect(tryAcquireRelayRepairLock(conn, '/r')).resolves.toBe('error') + }) + + it('recovers a stale best-effort repair lock without polling', async () => { + mockExec + .mockResolvedValueOnce('OPEN') + .mockResolvedValueOnce('') + .mockResolvedValueOnce('BUSY') + .mockResolvedValueOnce('OK') + .mockResolvedValueOnce('OPEN') + + await expect(tryAcquireRelayRepairLock(conn, '/r')).resolves.toBe('acquired') + + const commands = mockExec.mock.calls.map(([, command]) => command) + expect(commands.some((command) => command.includes('lock_tombstone'))).toBe(true) + expect(commands.filter((command) => command.includes('lock_tombstone'))).toHaveLength(1) + }) + + it('backs out when GC claims the sibling path during repair lock acquisition', async () => { + mockExec + .mockResolvedValueOnce('OPEN') + .mockResolvedValueOnce('') + .mockResolvedValueOnce('OK') + .mockResolvedValueOnce('LOCKED') + .mockResolvedValueOnce('') + + await expect(tryAcquireRelayRepairLock(conn, '/r')).resolves.toBe('gc') + + const lastCommand = mockExec.mock.calls.at(-1)?.[1] ?? '' + expect(lastCommand).toBe("rm -rf '/r/.install-lock'") + }) + + it('propagates cancellation through best-effort repair lock commands', async () => { + const abortController = new AbortController() + const abortError = Object.assign(new Error('cancelled'), { name: 'AbortError' }) + mockExec.mockRejectedValueOnce(abortError) + + const promise = tryAcquireRelayRepairLock(conn, '/r', undefined, { + signal: abortController.signal + }) + abortController.abort() + + await expect(promise).rejects.toMatchObject({ name: 'AbortError' }) + expect(mockExec.mock.calls[0]?.[2]?.signal).toBe(abortController.signal) }) it('polls until the lock becomes available (concurrent installer wins, then we acquire)', async () => { vi.useFakeTimers() try { - // Sequence: - // 1. mkdir -p (parent dir prep) - // 2. mkdir lockDir → BUSY (someone else holds it) - // 3. mkdir lockDir → BUSY again - // 4. mkdir lockDir → OK (concurrent installer released) - mockExec - .mockResolvedValueOnce('') - .mockResolvedValueOnce('BUSY') - .mockResolvedValueOnce('BUSY') - .mockResolvedValueOnce('OK') + let createAttempts = 0 + mockExec.mockImplementation(async (_conn: unknown, command: string) => { + if (command.includes('.gc-claim')) { + return 'OPEN' + } + if (command.startsWith('mkdir -p')) { + return '' + } + if (command.includes('lock_tombstone')) { + return 'BUSY' + } + if (command.includes('.install-lock')) { + createAttempts++ + return createAttempts >= 3 ? 'OK' : 'BUSY' + } + return '' + }) const promise = acquireInstallLock(conn, '/r') // Drive the polling loop: each iteration awaits a 1s timer. @@ -159,36 +283,48 @@ describe('acquireInstallLock', () => { } }) - it('steals a stale lock and retries with a reset timeout window', async () => { + it('immediately tries to steal an already-stale lock', async () => { + mockExec + .mockResolvedValueOnce('OPEN') + .mockResolvedValueOnce('') + .mockResolvedValueOnce('BUSY') + .mockResolvedValueOnce('OK') + .mockResolvedValueOnce('OPEN') + + await expect(acquireInstallLock(conn, '/r')).resolves.toBeUndefined() + + const cmds = mockExec.mock.calls.map(([, c]) => c) + expect(cmds).toHaveLength(5) + expect(cmds[3]).toContain('lock_tombstone') + }) + + it('retries stale takeover when a fresh lock ages out during the wait', async () => { vi.useFakeTimers({ now: 1_700_000_000_000 }) try { - let mkdirCalls = 0 + const recoverableAt = Date.now() + 60_000 mockExec.mockImplementation(async (_conn: unknown, cmd: string) => { + if (cmd.includes('.gc-claim')) { + return 'OPEN' + } if (cmd.startsWith('mkdir -p')) { return '' } + if (cmd.includes('lock_tombstone')) { + return Date.now() >= recoverableAt ? 'OK' : 'BUSY' + } if (cmd.includes('mkdir') && cmd.includes('.install-lock')) { - mkdirCalls++ - return mkdirCalls > 200 ? 'OK' : 'BUSY' - } - if (cmd.includes('stat')) { - return `${Math.floor((Date.now() - 10 * 60 * 1000) / 1000)}\n` - } - if (cmd.startsWith('rm -rf')) { - mkdirCalls = 1000 - return '' + return 'BUSY' } return '' }) const promise = acquireInstallLock(conn, '/r') - // Drive through the full timeout (120s) so the stale-recovery branch - // fires, then drive a few more seconds for the post-recovery retry. - await vi.advanceTimersByTimeAsync(125_000) - await promise + await vi.advanceTimersByTimeAsync(61_000) - const cmds = mockExec.mock.calls.map(([, c]) => c) - expect(cmds.some((c) => c.includes('rm -rf') && c.includes('.install-lock'))).toBe(true) + await expect(promise).resolves.toBeUndefined() + expect(mockExec.mock.calls.filter(([, cmd]) => cmd.includes('lock_tombstone'))).toHaveLength( + 2 + ) } finally { vi.useRealTimers() } @@ -198,26 +334,86 @@ describe('acquireInstallLock', () => { vi.useFakeTimers({ now: 1_700_000_000_000 }) try { mockExec.mockImplementation(async (_conn: unknown, cmd: string) => { + if (cmd.includes('.gc-claim')) { + return 'OPEN' + } if (cmd.startsWith('mkdir -p')) { return '' } + if (cmd.includes('lock_tombstone')) { + return 'BUSY' + } if (cmd.includes('mkdir') && cmd.includes('.install-lock')) { return 'BUSY' } if (cmd.includes('stat')) { - return `${Math.floor(Date.now() / 1000)}\n` + return '0\n' } return '' }) - const rejection = expect(acquireInstallLock(conn, '/r')).rejects.toThrow(/not yet stale/i) - await vi.advanceTimersByTimeAsync(125_000) + const rejection = expect(acquireInstallLock(conn, '/r')).rejects.toThrow( + /another install is still in progress/i + ) + await vi.advanceTimersByTimeAsync(905_000) await rejection } finally { vi.useRealTimers() } }) + it('stops polling when the caller aborts the lock wait', async () => { + vi.useFakeTimers() + try { + const abortController = new AbortController() + mockExec.mockImplementation(async (_conn: unknown, cmd: string) => + cmd.includes('.gc-claim') ? 'OPEN' : 'BUSY' + ) + + const promise = acquireInstallLock(conn, '/r', undefined, { + signal: abortController.signal + }) + await vi.advanceTimersByTimeAsync(1_000) + abortController.abort() + await expect(promise).rejects.toMatchObject({ name: 'AbortError' }) + + const callCountAfterAbort = mockExec.mock.calls.length + await vi.advanceTimersByTimeAsync(10_000) + expect(mockExec).toHaveBeenCalledTimes(callCountAfterAbort) + } finally { + vi.useRealTimers() + } + }) + + it('keeps waiting while a slow first installer is within the deploy bound', async () => { + vi.useFakeTimers({ now: 1_700_000_000_000 }) + try { + const availableAt = Date.now() + 500_000 + mockExec.mockImplementation(async (_conn: unknown, cmd: string) => { + if (cmd.includes('.gc-claim')) { + return 'OPEN' + } + if (cmd.startsWith('mkdir -p')) { + return '' + } + if (cmd.includes('mkdir') && cmd.includes('.install-lock')) { + return Date.now() >= availableAt ? 'OK' : 'BUSY' + } + return '' + }) + + const promise = acquireInstallLock(conn, '/r') + await vi.advanceTimersByTimeAsync(501_000) + + await expect(promise).resolves.toBeUndefined() + expect( + mockExec.mock.calls.filter(([, cmd]) => cmd.includes('lock_tombstone')).length + ).toBeGreaterThan(1) + } finally { + vi.useRealTimers() + } + }) + it('finalizeInstall writes .install-complete then removes the lock', async () => { mockExec.mockResolvedValueOnce('').mockResolvedValueOnce('') await finalizeInstall(conn, '/r') @@ -238,26 +434,220 @@ describe('acquireInstallLock', () => { }) }) +describe('relay GC claim', () => { + beforeEach(() => { + vi.clearAllMocks() + mockExec.mockReset() + }) + + it('parses explicit claim markers through remote startup noise', async () => { + mockExec.mockResolvedValueOnce('Welcome to host\nLOCKED\n') + await expect(isRelayGcClaimed(conn, '/relay/version')).resolves.toBe(true) + + mockExec.mockResolvedValueOnce('Last login: today\nOPEN\n') + await expect(isRelayGcClaimed(conn, '/relay/version')).resolves.toBe(false) + }) + + it('rejects missing or conflicting claim markers', async () => { + mockExec.mockResolvedValueOnce('Welcome to host\n') + await expect(isRelayGcClaimed(conn, '/relay/version')).rejects.toThrow('Inconclusive') + + mockExec.mockResolvedValueOnce('LOCKED\nOPEN\n') + await expect(isRelayGcClaimed(conn, '/relay/version')).rejects.toThrow('Inconclusive') + }) + + it('uses a stable sibling path outside the recursively deleted install', async () => { + mockExec.mockResolvedValueOnce('OK').mockResolvedValueOnce('') + + await expect(tryAcquireRelayGcClaim(conn, '/relay/version')).resolves.toEqual( + expect.any(String) + ) + + expect(relayGcClaimPath('/relay/version')).toBe('/relay/version.gc-claim') + expect(mockExec.mock.calls[0]?.[1]).toContain("mkdir '/relay/version.gc-claim'") + }) + + it('recovers and removes a stale sibling claim before retrying deploy', async () => { + mockExec + .mockResolvedValueOnce('LOCKED') + .mockResolvedValueOnce('OK') + .mockResolvedValueOnce('') + .mockResolvedValueOnce('RELEASED') + + await waitForRelayGcClaimRelease(conn, '/relay/version') + + const commands = mockExec.mock.calls.map(([, command]) => command) + expect(commands[1]).toContain('lock_tombstone') + expect(commands[3]).toContain("rm -rf '/relay/version.gc-claim'") + }) + + it('keeps waiting after losing a recovered claim until the claim is actually gone', async () => { + mockExec + .mockResolvedValueOnce('LOCKED') + .mockResolvedValueOnce('OK') + .mockResolvedValueOnce('') + .mockResolvedValueOnce('LOST') + .mockResolvedValueOnce('OPEN') + + await waitForRelayGcClaimRelease(conn, '/relay/version') + + expect(mockExec).toHaveBeenCalledTimes(5) + }) + + it('writes and conditionally releases the Windows sibling claim owner token', async () => { + const windows = getRemoteHostPlatform('win32-x64') + mockExec.mockResolvedValueOnce('OK').mockResolvedValueOnce('').mockResolvedValueOnce('RELEASED') + + const token = await tryAcquireRelayGcClaim(conn, 'C:/relay/version', windows) + expect(token).toEqual(expect.any(String)) + await expect(releaseRelayGcClaim(conn, 'C:/relay/version', token!, windows)).resolves.toBe( + 'released' + ) + + const ownerScript = decodePowerShellCommand(mockExec.mock.calls[1]?.[1] ?? '') + const releaseScript = decodePowerShellCommand(mockExec.mock.calls[2]?.[1] ?? '') + expect(ownerScript).toContain('Set-Content -LiteralPath') + expect(ownerScript).toContain('.gc-claim/.gc-owner') + expect(releaseScript).toContain('Get-Content -LiteralPath') + expect(releaseScript).toContain('-cne') + expect(releaseScript).toContain('Remove-Item -LiteralPath') + expect(releaseScript).toContain("'RELEASED'") + expect(releaseScript).toContain("'LOST'") + expect(releaseScript).toContain("'UNKNOWN'") + expect(releaseScript).not.toContain('}; elseif') + expect(releaseScript).not.toContain('{;') + }) + + it('conditionally releases a claim when the owner write reply is lost', async () => { + mockExec + .mockResolvedValueOnce('OK') + .mockRejectedValueOnce(new Error('owner write reply lost')) + .mockResolvedValueOnce('RELEASED') + + await expect(tryAcquireRelayGcClaim(conn, '/relay/version')).resolves.toBeNull() + + const ownerCommand = mockExec.mock.calls[1]?.[1] ?? '' + const releaseCommand = mockExec.mock.calls[2]?.[1] ?? '' + const token = ownerCommand.match(/printf %s '([^']+)'/)?.[1] + expect(token).toBeTruthy() + expect(releaseCommand).toContain(`!= '${token}'`) + }) +}) + describe('gcOldRelayVersions', () => { beforeEach(() => { vi.clearAllMocks() + mockExec.mockReset() }) it('removes a sibling that is complete, unlocked, and has no live socket', async () => { // ls listing mockExec.mockResolvedValueOnce('relay-0.1.0+aaa\nrelay-0.1.0+bbb\n') - // For sibling "aaa": LOCKED probe → OPEN, COMPLETE probe → COMPLETE, sock probe → empty (no ALIVE), then rm -rf + // For sibling "aaa": safety probes pass, then GC claims the install lock before removal. mockExec .mockResolvedValueOnce('OPEN') .mockResolvedValueOnce('COMPLETE') + .mockResolvedValueOnce('DEAD') + .mockResolvedValueOnce('OK') .mockResolvedValueOnce('') + .mockResolvedValueOnce('OPEN') + .mockResolvedValueOnce('COMPLETE') + .mockResolvedValueOnce('DEAD') + .mockResolvedValueOnce('OWNED') + .mockResolvedValueOnce('MOVED') + .mockResolvedValueOnce('RELEASED') .mockResolvedValueOnce('') await gcOldRelayVersions(conn, '/home/u', '/home/u/.orca-remote/relay-0.1.0+bbb') const lastCmd = mockExec.mock.calls.at(-1)?.[1] ?? '' expect(lastCmd).toContain('rm -rf') - expect(lastCmd).toContain('relay-0.1.0+aaa') + expect(lastCmd).toContain('relay-0.1.0+aaa.gc-tombstone') + const commands = mockExec.mock.calls.map(([, command]) => command) + expect( + commands.some((command) => command === "rm -rf '/home/u/.orca-remote/relay-0.1.0+aaa'") + ).toBe(false) + }) + + it('cleans only strict POSIX orphan tombstones even with no relay candidates', async () => { + mockExec + .mockResolvedValueOnce( + [ + 'relay-0.1.0+abc.gc-tombstone.123.456', + 'relay-0.1.0+abc.gc-tombstone.bad.456', + 'xrelay-0.1.0+abc.gc-tombstone.123.456', + 'relay-0.1.0+abc.gc-tombstone.123.456.extra', + 'relay-0.1.0+abc.gc-tombstone.123.456/child', + 'logs' + ].join('\n') + ) + .mockResolvedValueOnce('') + + await gcOldRelayVersions(conn, '/home/u', '/home/u/.orca-remote/relay-0.1.0+bbb') + + const removeCommands = mockExec.mock.calls + .map(([, command]) => command) + .filter((command) => command.startsWith('rm -rf')) + expect(removeCommands).toEqual([ + "rm -rf '/home/u/.orca-remote/relay-0.1.0+abc.gc-tombstone.123.456'" + ]) + }) + + it('cleans only strict Windows orphan tombstones even with no relay candidates', async () => { + const windows = getRemoteHostPlatform('win32-x64') + mockExec + .mockResolvedValueOnce( + 'relay-v0.1.0.gc-tombstone.123.456\nrelay-v0.1.0.gc-tombstone.latest.456\n' + ) + .mockResolvedValueOnce('') + + await gcOldRelayVersions(conn, 'C:/Users/u', 'C:/Users/u/.orca-remote/relay-0.1.0+bbb', windows) + + const removeScript = decodePowerShellCommand(mockExec.mock.calls[1]?.[1] ?? '') + expect(removeScript).toContain('relay-v0.1.0.gc-tombstone.123.456') + expect(removeScript).not.toContain('relay-v0.1.0.gc-tombstone.latest.456') + }) + + it('retries orphan tombstone cleanup on a later GC pass', async () => { + const tombstone = 'relay-0.1.0+abc.gc-tombstone.123.456' + mockExec + .mockResolvedValueOnce(tombstone) + .mockRejectedValueOnce(new Error('remove failed')) + .mockResolvedValueOnce(tombstone) + .mockResolvedValueOnce('') + + await gcOldRelayVersions(conn, '/home/u', '/home/u/.orca-remote/relay-0.1.0+bbb') + await gcOldRelayVersions(conn, '/home/u', '/home/u/.orca-remote/relay-0.1.0+bbb') + + const removeCommands = mockExec.mock.calls + .map(([, command]) => command) + .filter((command) => command.startsWith('rm -rf')) + expect(removeCommands).toHaveLength(2) + }) + + it('retries only an unknown claim release and stops after observing a lost generation', async () => { + mockExec + .mockResolvedValueOnce('relay-0.1.0+aaa\n') + .mockResolvedValueOnce('OPEN') + .mockResolvedValueOnce('COMPLETE') + .mockResolvedValueOnce('DEAD') + .mockResolvedValueOnce('OK') + .mockResolvedValueOnce('') + .mockResolvedValueOnce('OPEN') + .mockResolvedValueOnce('COMPLETE') + .mockResolvedValueOnce('DEAD') + .mockResolvedValueOnce('OWNED') + .mockResolvedValueOnce('MOVED') + .mockResolvedValueOnce('UNKNOWN') + .mockResolvedValueOnce('LOST') + .mockResolvedValueOnce('') + + await gcOldRelayVersions(conn, '/home/u', '/home/u/.orca-remote/relay-0.1.0+bbb') + + const releaseCommands = mockExec.mock.calls + .map(([, command]) => command) + .filter((command) => command.includes('.gc-owner') && command.includes('echo RELEASED')) + expect(releaseCommands).toHaveLength(2) }) it('skips siblings that are missing .install-complete (mid-install or partial)', async () => { @@ -273,8 +663,8 @@ describe('gcOldRelayVersions', () => { it('skips siblings whose .install-lock is held and fresh', async () => { mockExec.mockResolvedValueOnce('relay-0.1.0+aaa\n') mockExec.mockResolvedValueOnce('LOCKED') - // isLockStale: mtime ~now → not stale. - mockExec.mockResolvedValueOnce(`${Math.floor(Date.now() / 1000)}\n`) + // isLockStale: age ~now → not stale. + mockExec.mockResolvedValueOnce('0\n') await gcOldRelayVersions(conn, '/home/u', '/home/u/.orca-remote/relay-0.1.0+bbb') const cmds = mockExec.mock.calls.map(([, c]) => c) expect(cmds.some((c) => c.includes('rm -rf'))).toBe(false) @@ -283,12 +673,20 @@ describe('gcOldRelayVersions', () => { it('removes a sibling with a stale lock + .install-complete (rm-lock failed mid-finalize)', async () => { mockExec.mockResolvedValueOnce('relay-0.1.0+aaa\n') mockExec.mockResolvedValueOnce('LOCKED') - // isLockStale: mtime well in the past → stale. - const staleSec = Math.floor((Date.now() - 10 * 60 * 1000) / 1000) - mockExec.mockResolvedValueOnce(`${staleSec}\n`) + // isLockStale: age well above the stale window → stale. + mockExec.mockResolvedValueOnce(`${21 * 60}\n`) mockExec.mockResolvedValueOnce('COMPLETE') // .install-complete present - mockExec.mockResolvedValueOnce('') // socket probe → no ALIVE - mockExec.mockResolvedValueOnce('') // rm -rf + mockExec.mockResolvedValueOnce('DEAD') // socket probe + mockExec.mockResolvedValueOnce('OK') // stable sibling GC claim + mockExec.mockResolvedValueOnce('') // write claim ownership token + mockExec.mockResolvedValueOnce('LOCKED') + mockExec.mockResolvedValueOnce(`${21 * 60}\n`) + mockExec.mockResolvedValueOnce('COMPLETE') + mockExec.mockResolvedValueOnce('DEAD') + mockExec.mockResolvedValueOnce('OWNED') + mockExec.mockResolvedValueOnce('MOVED') + mockExec.mockResolvedValueOnce('RELEASED') // release sibling claim + mockExec.mockResolvedValueOnce('') // remove tombstone await gcOldRelayVersions(conn, '/home/u', '/home/u/.orca-remote/relay-0.1.0+bbb') const lastCmd = mockExec.mock.calls.at(-1)?.[1] ?? '' expect(lastCmd).toContain('rm -rf') @@ -298,8 +696,15 @@ describe('gcOldRelayVersions', () => { it('GCs a legacy relay-v0.1.0 dir whose daemon is dead (no .install-complete required)', async () => { mockExec.mockResolvedValueOnce('relay-v0.1.0\n') mockExec.mockResolvedValueOnce('OPEN') // not locked - mockExec.mockResolvedValueOnce('') // socket probe → no ALIVE (no completeProbe — legacy) - mockExec.mockResolvedValueOnce('') // rm -rf + mockExec.mockResolvedValueOnce('DEAD') // socket probe (no completeProbe — legacy) + mockExec.mockResolvedValueOnce('OK') // GC claims candidate + mockExec.mockResolvedValueOnce('') + mockExec.mockResolvedValueOnce('OPEN') + mockExec.mockResolvedValueOnce('DEAD') + mockExec.mockResolvedValueOnce('OWNED') + mockExec.mockResolvedValueOnce('MOVED') + mockExec.mockResolvedValueOnce('RELEASED') + mockExec.mockResolvedValueOnce('') await gcOldRelayVersions(conn, '/home/u', '/home/u/.orca-remote/relay-0.1.0+bbb') const cmds = mockExec.mock.calls.map(([, c]) => c) expect(cmds.some((c) => c.includes('rm -rf') && c.includes('relay-v0.1.0'))).toBe(true) @@ -327,6 +732,32 @@ describe('gcOldRelayVersions', () => { expect(cmds.some((c) => c.includes('rm -rf'))).toBe(false) }) + it('keeps a sibling when the install-lock probe fails or returns unexpected output', async () => { + mockExec + .mockResolvedValueOnce('relay-0.1.0+aaa\nrelay-0.1.0+ccc\n') + .mockRejectedValueOnce(new Error('lock probe failed')) + .mockResolvedValueOnce('INCONCLUSIVE') + + await gcOldRelayVersions(conn, '/home/u', '/home/u/.orca-remote/relay-0.1.0+bbb') + + expect(mockExec).toHaveBeenCalledTimes(3) + }) + + it('keeps a sibling when the relay-liveness probe fails or returns unexpected output', async () => { + mockExec + .mockResolvedValueOnce('relay-0.1.0+aaa\nrelay-0.1.0+ccc\n') + .mockResolvedValueOnce('OPEN') + .mockResolvedValueOnce('COMPLETE') + .mockRejectedValueOnce(new Error('liveness probe failed')) + .mockResolvedValueOnce('OPEN') + .mockResolvedValueOnce('COMPLETE') + .mockResolvedValueOnce('INCONCLUSIVE') + + await gcOldRelayVersions(conn, '/home/u', '/home/u/.orca-remote/relay-0.1.0+bbb') + + expect(mockExec).toHaveBeenCalledTimes(7) + }) + it('probes Windows GC liveness by connecting to named pipes, not process command lines', async () => { const windows = getRemoteHostPlatform('win32-x64') mockExec.mockResolvedValueOnce('relay-0.1.0+aaa\n') @@ -334,6 +765,14 @@ describe('gcOldRelayVersions', () => { .mockResolvedValueOnce('OPEN') .mockResolvedValueOnce('COMPLETE') .mockResolvedValueOnce('WAITING') + .mockResolvedValueOnce('OK') + .mockResolvedValueOnce('') + .mockResolvedValueOnce('OPEN') + .mockResolvedValueOnce('COMPLETE') + .mockResolvedValueOnce('WAITING') + .mockResolvedValueOnce('OWNED') + .mockResolvedValueOnce('MOVED') + .mockResolvedValueOnce('RELEASED') .mockResolvedValueOnce('') await gcOldRelayVersions( @@ -366,14 +805,113 @@ describe('gcOldRelayVersions', () => { mockExec .mockResolvedValueOnce('OPEN') .mockResolvedValueOnce('COMPLETE') + .mockResolvedValueOnce('DEAD') + .mockResolvedValueOnce('OK') .mockResolvedValueOnce('') + .mockResolvedValueOnce('OPEN') + .mockResolvedValueOnce('COMPLETE') + .mockResolvedValueOnce('DEAD') + .mockResolvedValueOnce('OWNED') + .mockResolvedValueOnce('MOVED') + .mockResolvedValueOnce('RELEASED') .mockResolvedValueOnce('') await gcOldRelayVersions(conn, '/home/u', '/home/u/.orca-remote/relay-0.1.0+bbb') const cmds = mockExec.mock.calls.map(([, c]) => c) - const rmCmds = cmds.filter((c) => c.includes('rm -rf')) + const rmCmds = cmds.filter((c) => c.startsWith('rm') && c.includes('gc-tombstone')) expect(rmCmds).toHaveLength(1) expect(rmCmds[0]).toContain('relay-0.1.0+aaa') expect(rmCmds[0]).not.toContain('logs') expect(rmCmds[0]).not.toContain('backup') }) + + it('does not remove a candidate claimed by repair after the initial lock probe', async () => { + mockExec + .mockResolvedValueOnce('relay-0.1.0+aaa\n') + .mockResolvedValueOnce('OPEN') + .mockResolvedValueOnce('COMPLETE') + .mockResolvedValueOnce('DEAD') // socket probe + .mockResolvedValueOnce('OK') // GC sibling claim + .mockResolvedValueOnce('') // write claim ownership token + .mockResolvedValueOnce('LOCKED') // repair won before the safety recheck + .mockResolvedValueOnce('0') + .mockResolvedValueOnce('RELEASED') // release GC sibling claim + + await gcOldRelayVersions(conn, '/home/u', '/home/u/.orca-remote/relay-0.1.0+bbb') + + const commands = mockExec.mock.calls.map(([, command]) => command) + expect( + commands.some( + (command) => + command.startsWith("rm -rf '/home/u/.orca-remote/relay-0.1.0+aaa'") && + !command.includes('.install-lock') + ) + ).toBe(false) + }) + + it('does not move a candidate after losing the sibling claim generation', async () => { + mockExec + .mockResolvedValueOnce('relay-0.1.0+aaa\n') + .mockResolvedValueOnce('OPEN') + .mockResolvedValueOnce('COMPLETE') + .mockResolvedValueOnce('DEAD') + .mockResolvedValueOnce('OK') + .mockResolvedValueOnce('') + .mockResolvedValueOnce('OPEN') + .mockResolvedValueOnce('COMPLETE') + .mockResolvedValueOnce('DEAD') + .mockResolvedValueOnce('LOST') + .mockResolvedValueOnce('LOST') + + await gcOldRelayVersions(conn, '/home/u', '/home/u/.orca-remote/relay-0.1.0+bbb') + + const commands = mockExec.mock.calls.map(([, command]) => command) + expect(commands.some((command) => command.startsWith('mv '))).toBe(false) + }) + + it('releases the GC claim after a confirmed move failure', async () => { + mockExec + .mockResolvedValueOnce('relay-0.1.0+aaa\n') + .mockResolvedValueOnce('OPEN') + .mockResolvedValueOnce('COMPLETE') + .mockResolvedValueOnce('DEAD') + .mockResolvedValueOnce('OK') + .mockResolvedValueOnce('') + .mockResolvedValueOnce('OPEN') + .mockResolvedValueOnce('COMPLETE') + .mockResolvedValueOnce('DEAD') + .mockResolvedValueOnce('OWNED') + .mockRejectedValueOnce(new Error('move failed')) + .mockResolvedValueOnce('RELEASED') + + await gcOldRelayVersions(conn, '/home/u', '/home/u/.orca-remote/relay-0.1.0+bbb') + + const lastCommand = mockExec.mock.calls.at(-1)?.[1] ?? '' + expect(lastCommand).toContain('rm -rf') + expect(lastCommand).toContain('relay-0.1.0+aaa.gc-claim') + }) + + it('keeps the GC claim when remote move termination is unconfirmed', async () => { + const unconfirmed = Object.assign(new Error('move timed out'), { + sshChannelCloseConfirmed: false + }) + mockExec + .mockResolvedValueOnce('relay-0.1.0+aaa\n') + .mockResolvedValueOnce('OPEN') + .mockResolvedValueOnce('COMPLETE') + .mockResolvedValueOnce('DEAD') + .mockResolvedValueOnce('OK') + .mockResolvedValueOnce('') + .mockResolvedValueOnce('OPEN') + .mockResolvedValueOnce('COMPLETE') + .mockResolvedValueOnce('DEAD') + .mockResolvedValueOnce('OWNED') + .mockRejectedValueOnce(unconfirmed) + + await gcOldRelayVersions(conn, '/home/u', '/home/u/.orca-remote/relay-0.1.0+bbb') + + const releaseCommands = mockExec.mock.calls + .map(([, command]) => command) + .filter((command) => command.includes('relay-0.1.0+aaa.gc-claim') && command.startsWith('rm')) + expect(releaseCommands).toHaveLength(0) + }) }) diff --git a/src/main/ssh/ssh-relay-versioned-install.ts b/src/main/ssh/ssh-relay-versioned-install.ts index a669f0b7875..b211fec4dd0 100644 --- a/src/main/ssh/ssh-relay-versioned-install.ts +++ b/src/main/ssh/ssh-relay-versioned-install.ts @@ -15,16 +15,21 @@ import { existsSync, readFileSync } from 'node:fs' import type { SshConnection } from './ssh-connection' import { RELAY_REMOTE_DIR } from './relay-protocol' import { execCommand } from './ssh-relay-deploy-helpers' +import { probeInstallLockExistsCommand } from './ssh-relay-install-lock-commands' +import { isRelayInstallLockStale, RELAY_INSTALL_LOCK_NAME } from './ssh-relay-install-lock' +import { + isRelayGcClaimOwned, + releaseRelayGcClaimWithRetry, + tryAcquireRelayGcClaim +} from './ssh-relay-gc-claim' +import { cleanupRelayGcTombstones } from './ssh-relay-gc-tombstone' import { - acquireInstallLockParentCommand, listRelayBaseDirsCommand, - lockMtimeEpochCommand, - probeDirectoryExistsCommand, + moveRemoteTreeCommand, probeFileExistsCommand, probeRelayInstalledCommand, relayLivenessProbeCommand, removeRemoteTreeCommand, - tryCreateInstallLockCommand, writeRemoteEmptyFileCommand } from './ssh-remote-commands' import { @@ -36,6 +41,7 @@ import { type RemotePathFlavor } from './ssh-remote-platform' import { windowsRelayPipePathsForSocketName } from './ssh-relay-endpoints' +import { isUnconfirmedSshCommandTermination } from './ssh-relay-exec-command' import { isSshSessionLimitError } from './ssh-session-limit-error' // Why: the GC pass and the version-dir parser must agree on what counts as a @@ -51,18 +57,7 @@ const RELAY_VERSION_DIR_REGEX = /^relay-(v?\d+\.\d+\.\d+(\+[0-9a-f]+)?)$/ // living on remote disks forever. const LEGACY_RELAY_DIR_REGEX = /^relay-v\d+\.\d+\.\d+$/ -const INSTALL_LOCK_NAME = '.install-lock' const INSTALL_COMPLETE_NAME = '.install-complete' - -const INSTALL_LOCK_POLL_MS = 1_000 -const INSTALL_LOCK_TIMEOUT_MS = 120_000 -// Why: a stale lock dir from a crashed installer must be recoverable without -// user intervention. After the timeout we check the lock's mtime; if it's -// older than this window the previous installer is assumed dead and we steal -// the lock. 2 minutes is well above a normal `npm install node-pty` runtime -// (10–60s on slow hosts) so a slow concurrent installer is not falsely -// declared dead. -const INSTALL_LOCK_STALE_MS = 120_000 const DEFAULT_REMOTE_HOST = getRemoteHostPlatform('linux-x64') type RelayInstalledProbeOptions = { @@ -145,6 +140,7 @@ export async function isRelayAlreadyInstalled( ) return probe.trim() === 'OK' } catch (err) { + options?.signal?.throwIfAborted() if (options?.rethrowSessionLimitErrors && isSshSessionLimitError(err)) { throw err } @@ -153,101 +149,27 @@ export async function isRelayAlreadyInstalled( } /** - * Acquire the per-version install lock via atomic `mkdir`. Returns when the - * caller owns the lock; throws if the lock could not be acquired within - * INSTALL_LOCK_TIMEOUT_MS even after one stale-lock recovery attempt. - * - * Why mkdir: POSIX `mkdir` is atomic and fails with EEXIST if the dir already - * exists, giving us a free mutex. A second concurrent caller polls and - * eventually either acquires the lock or steals it after the stale window. - */ -export async function acquireInstallLock( - conn: SshConnection, - remoteRelayDir: string, - host: RemoteHostPlatform = DEFAULT_REMOTE_HOST -): Promise { - const lockDir = joinRemotePath(host, remoteRelayDir, INSTALL_LOCK_NAME) - // Why: the parent dir may not exist yet on a first install. mkdir -p is - // safe to run multiple times — it's a no-op if the dir already exists. - await execHostCommand(conn, host, acquireInstallLockParentCommand(host, remoteRelayDir)) - - let start = Date.now() - let recoveredOnce = false - while (true) { - try { - const result = await execHostCommand(conn, host, tryCreateInstallLockCommand(host, lockDir)) - if (result.trim().endsWith('OK')) { - return - } - } catch { - /* mkdir failed with non-zero — fall through to BUSY treatment */ - } - if (Date.now() - start >= INSTALL_LOCK_TIMEOUT_MS) { - if (recoveredOnce) { - throw new Error( - `Could not acquire relay install lock at ${lockDir} after ${ - INSTALL_LOCK_TIMEOUT_MS / 1000 - }s; another install is in progress or the lock is wedged.` - ) - } - // Stale-lock recovery: if the lock dir's mtime is older than the stale - // window, the previous installer crashed. Steal it and retry once, - // resetting the timeout window so a single post-recovery race doesn't - // immediately exhaust the budget. - const ageOk = await isLockStale(conn, lockDir, host) - if (ageOk) { - console.warn(`[ssh-relay] Stealing stale install lock at ${lockDir}`) - await execHostCommand(conn, host, removeRemoteTreeCommand(host, lockDir)).catch(() => {}) - recoveredOnce = true - start = Date.now() - continue - } - throw new Error( - `Could not acquire relay install lock at ${lockDir} after ${ - INSTALL_LOCK_TIMEOUT_MS / 1000 - }s and the lock is not yet stale.` - ) - } - await new Promise((r) => setTimeout(r, INSTALL_LOCK_POLL_MS)) - } -} - -async function isLockStale( - conn: SshConnection, - lockDir: string, - host: RemoteHostPlatform = DEFAULT_REMOTE_HOST -): Promise { - try { - // Why: `stat` flags differ between GNU coreutils (Linux) and BSD (macOS). - // We try GNU first, then BSD; both produce a Unix epoch in seconds on - // stdout. If both fail we conservatively treat the lock as not stale. - const out = await execHostCommand(conn, host, lockMtimeEpochCommand(host, lockDir)) - const mtimeSec = Number.parseInt(out.trim(), 10) - if (!Number.isFinite(mtimeSec)) { - return false - } - const ageMs = Date.now() - mtimeSec * 1000 - return ageMs > INSTALL_LOCK_STALE_MS - } catch { - return false - } -} - -/** - * Mark the install as complete and release the lock. Sentinel ordering is: - * write `.install-complete` FIRST, then remove `.install-lock`. This ensures - * a sibling dir is never observed by GC as "complete but locked", which - * would lead GC to skip a recoverable dir indefinitely. + * Mark the install as complete, then normally release the lock. Deploy keeps + * the lock through first launch so cross-version GC cannot move the directory + * between finalization and daemon liveness becoming observable. */ export async function finalizeInstall( conn: SshConnection, remoteRelayDir: string, - host: RemoteHostPlatform = DEFAULT_REMOTE_HOST + host: RemoteHostPlatform = DEFAULT_REMOTE_HOST, + options?: { signal?: AbortSignal; releaseLock?: boolean } ): Promise { const sentinel = joinRemotePath(host, remoteRelayDir, INSTALL_COMPLETE_NAME) - const lock = joinRemotePath(host, remoteRelayDir, INSTALL_LOCK_NAME) - await execHostCommand(conn, host, writeRemoteEmptyFileCommand(host, sentinel)) - await execHostCommand(conn, host, removeRemoteTreeCommand(host, lock)).catch(() => {}) + const lock = joinRemotePath(host, remoteRelayDir, RELAY_INSTALL_LOCK_NAME) + await execHostCommand(conn, host, writeRemoteEmptyFileCommand(host, sentinel), { + signal: options?.signal + }) + if (options?.releaseLock !== false) { + await execHostCommand(conn, host, removeRemoteTreeCommand(host, lock), { + signal: options?.signal + }).catch(() => {}) + } + options?.signal?.throwIfAborted() } /** @@ -260,7 +182,7 @@ export async function abandonInstall( remoteRelayDir: string, host: RemoteHostPlatform = DEFAULT_REMOTE_HOST ): Promise { - const lock = joinRemotePath(host, remoteRelayDir, INSTALL_LOCK_NAME) + const lock = joinRemotePath(host, remoteRelayDir, RELAY_INSTALL_LOCK_NAME) await execHostCommand(conn, host, removeRemoteTreeCommand(host, lock)).catch(() => {}) } @@ -295,10 +217,14 @@ export async function gcOldRelayVersions( } catch { return } - const candidates = listing + const entries = listing .split('\n') .map((s) => s.trim()) .filter(Boolean) + + await cleanupRelayGcTombstones(conn, baseDir, entries, host) + + const candidates = entries .filter((name) => RELAY_VERSION_DIR_REGEX.test(name)) .filter((name) => name !== currentDirName) @@ -316,7 +242,47 @@ export async function gcOldRelayVersions( kept.push(name) continue } - await execHostCommand(conn, host, removeRemoteTreeCommand(host, dir)) + // Why: the claim is a sibling, so it survives moving/deleting the + // candidate and lets installers back out before mutating the old path. + const gcClaimToken = await tryAcquireRelayGcClaim(conn, dir, host) + if (!gcClaimToken) { + kept.push(name) + continue + } + let preserveGcClaim = false + let gcClaimReleaseNeeded = true + try { + // Recheck under the stable claim. New installers probe the claim both + // before and after creating their in-tree lock, closing both orders. + if (!(await isCandidateSafeToRemove(conn, dir, name, host, options))) { + kept.push(name) + continue + } + if (!(await isRelayGcClaimOwned(conn, dir, gcClaimToken, host))) { + kept.push(name) + continue + } + const tombstone = `${dir}.gc-tombstone.${process.pid}.${Date.now()}` + const moved = await execHostCommand(conn, host, moveRemoteTreeCommand(host, dir, tombstone)) + if (moved.trim() !== 'MOVED') { + kept.push(name) + continue + } + // Once renamed, a fresh install at the original path is isolated from + // deletion of the tombstone, so the sibling claim can be released. + const release = await releaseRelayGcClaimWithRetry(conn, dir, gcClaimToken, host) + gcClaimReleaseNeeded = release === 'unknown' + await execHostCommand(conn, host, removeRemoteTreeCommand(host, tombstone)) + } catch (err) { + if (isUnconfirmedSshCommandTermination(err)) { + preserveGcClaim = true + } + throw err + } finally { + if (!preserveGcClaim && gcClaimReleaseNeeded) { + await releaseRelayGcClaimWithRetry(conn, dir, gcClaimToken, host) + } + } removed.push(name) } catch (err) { console.warn( @@ -346,23 +312,28 @@ async function isCandidateSafeToRemove( ): Promise { const isLegacy = LEGACY_RELAY_DIR_REGEX.test(name) - const lockDir = joinRemotePath(host, dir, INSTALL_LOCK_NAME) - const lockProbe = await execHostCommand( - conn, - host, - probeDirectoryExistsCommand(host, lockDir) - ).catch(() => 'OPEN') - const locked = lockProbe.trim() === 'LOCKED' + const lockDir = joinRemotePath(host, dir, RELAY_INSTALL_LOCK_NAME) + let lockProbe: string + try { + lockProbe = await execHostCommand(conn, host, probeInstallLockExistsCommand(host, lockDir)) + } catch { + return false + } + const lockState = lockProbe.trim() + if (lockState !== 'OPEN' && lockState !== 'LOCKED') { + return false + } + const locked = lockState === 'LOCKED' if (locked) { // Why: a locked dir is normally unsafe to remove — but a STALE lock - // (mtime older than INSTALL_LOCK_STALE_MS) means the previous installer + // (remote age older than INSTALL_LOCK_STALE_MS) means the previous installer // crashed and is never coming back. If the dir also has the // .install-complete sentinel (touch succeeded but the rm-lock at the // end of finalizeInstall failed), removing the dir is safe — no // installer is racing us, and the daemon (if any) keeps running off // its already-loaded code regardless of disk state. - if (!(await isLockStale(conn, lockDir, host))) { + if (!(await isRelayInstallLockStale(conn, lockDir, host))) { return false } process.stderr.write?.(`[ssh-relay] GC: lock at ${lockDir} is stale; treating as recoverable\n`) @@ -419,8 +390,10 @@ async function hasLiveRelaySocket( host, relayLivenessProbeCommand(host, dir, windowsOptions) ) - return out.includes('ALIVE') + const state = out.trim() + return state !== 'DEAD' && state !== 'WAITING' } catch { - return false + // Why: an inconclusive liveness probe must never authorize deletion. + return true } } diff --git a/src/main/ssh/ssh-remote-commands.test.ts b/src/main/ssh/ssh-remote-commands.test.ts index 952daf9edde..96cc8925515 100644 --- a/src/main/ssh/ssh-remote-commands.test.ts +++ b/src/main/ssh/ssh-remote-commands.test.ts @@ -1,24 +1,109 @@ +import { execFileSync, spawn, spawnSync } from 'node:child_process' +import { + existsSync, + mkdirSync, + mkdtempSync, + readdirSync, + rmSync, + statSync, + utimesSync +} from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' import { describe, expect, it } from 'vitest' +import { + lockAgeSecondsCommand, + tryCreateInstallLockCommand, + tryStealInstallLockCommand +} from './ssh-relay-install-lock-commands' import { commandInRemoteDirectory, commandWithNodePath, listRelayBaseDirsCommand, makeRemoteDirectoryCommand, + moveRemoteTreeCommand, + probeDirectoryExistsCommand, probeRelayInstalledCommand, readRemoteHomeCommand, - relayLivenessProbeCommand, - tryCreateInstallLockCommand + relayLivenessProbeCommand } from './ssh-remote-commands' import { getRemoteHostPlatform } from './ssh-remote-platform' const posix = getRemoteHostPlatform('linux-x64') const windows = getRemoteHostPlatform('win32-x64') +const powerShellExecutable = ( + process.platform === 'win32' ? ['pwsh.exe', 'powershell.exe'] : ['pwsh'] +).find((candidate) => { + const result = spawnSync(candidate, ['-NoProfile', '-NonInteractive', '-Command', 'exit 0'], { + stdio: 'ignore' + }) + return result.status === 0 +}) +const powerShell51Executable = + process.platform === 'win32' && + spawnSync('powershell.exe', ['-NoProfile', '-NonInteractive', '-Command', 'exit 0'], { + stdio: 'ignore' + }).status === 0 + ? 'powershell.exe' + : undefined function decodePowerShellCommand(command: string): string { const match = command.match(/-EncodedCommand\s+([A-Za-z0-9+/=]+)/) return match ? Buffer.from(match[1], 'base64').toString('utf16le') : '' } +function runShellCommand(command: string): Promise { + return new Promise((resolve, reject) => { + const child = spawn('/bin/sh', ['-c', command], { + stdio: ['ignore', 'pipe', 'pipe'] + }) + let stdout = '' + let stderr = '' + child.stdout.setEncoding('utf8') + child.stderr.setEncoding('utf8') + child.stdout.on('data', (chunk) => { + stdout += chunk + }) + child.stderr.on('data', (chunk) => { + stderr += chunk + }) + child.on('error', reject) + child.on('close', (code) => { + if (code === 0) { + resolve(stdout) + return + } + reject(new Error(`shell exited ${code}: ${stderr}`)) + }) + }) +} + +function runPowerShellCommand(executable: string, script: string): Promise { + return new Promise((resolve, reject) => { + const child = spawn(executable, ['-NoProfile', '-NonInteractive', '-Command', script], { + stdio: ['ignore', 'pipe', 'pipe'] + }) + let stdout = '' + let stderr = '' + child.stdout.setEncoding('utf8') + child.stderr.setEncoding('utf8') + child.stdout.on('data', (chunk) => { + stdout += chunk + }) + child.stderr.on('data', (chunk) => { + stderr += chunk + }) + child.on('error', reject) + child.on('close', (code) => { + if (code === 0) { + resolve(stdout) + return + } + reject(new Error(`PowerShell exited ${code}: ${stderr}`)) + }) + }) +} + describe('ssh remote command builders', () => { it('keeps POSIX deploy commands POSIX-native', () => { expect(readRemoteHomeCommand(posix)).toBe('echo $HOME') @@ -34,7 +119,7 @@ describe('ssh remote command builders', () => { expect(probeRelayInstalledCommand(windows, 'C:/Users/me/relay')).toContain('-EncodedCommand') }) - it('uses -Path for Windows New-Item commands', () => { + it('uses a legacy-visible Windows lock directory with an exclusive owner file', () => { const mkdirScript = decodePowerShellCommand( makeRemoteDirectoryCommand(windows, 'C:/Users/me/.orca-remote') ) @@ -43,9 +128,31 @@ describe('ssh remote command builders', () => { ) expect(mkdirScript).toContain('New-Item -ItemType Directory -Force -Path') - expect(lockScript).toContain('New-Item -ItemType Directory -Path') + expect(lockScript).toContain('[System.IO.FileMode]::CreateNew') + expect(lockScript).toContain('[System.IO.FileShare]::None') + expect(lockScript).toContain('New-Item -ItemType Directory -Path $lock') + expect(lockScript).toContain("Join-Path $lock '.owner'") expect(mkdirScript).not.toContain('New-Item -ItemType Directory -Force -LiteralPath') - expect(lockScript).not.toContain('New-Item -ItemType Directory -LiteralPath') + }) + + it('moves GC candidates to a sibling tombstone with host-native commands', () => { + expect(moveRemoteTreeCommand(posix, '/relay/old', '/relay/old.gc-tombstone')).toContain( + "mv '/relay/old' '/relay/old.gc-tombstone'" + ) + const windowsScript = decodePowerShellCommand( + moveRemoteTreeCommand(windows, 'C:/Users/me/relay/old', 'C:/Users/me/relay/old.gc-tombstone') + ) + expect(windowsScript).toContain('Move-Item -LiteralPath') + expect(windowsScript).toContain("-Destination 'C:/Users/me/relay/old.gc-tombstone'") + expect(windowsScript).toContain("'MOVED'") + }) + + it('emits an explicit POSIX liveness result so GC can fail closed', () => { + const command = relayLivenessProbeCommand(posix, '/home/u/.orca-remote/relay-0.1.0') + + expect(command).toContain('state=DEAD') + expect(command).toContain('[ -S "$f" ] && state=ALIVE') + expect(command).toContain('echo "$state"') }) it('uses named pipe try-connect liveness for Windows GC', () => { @@ -96,11 +203,227 @@ describe('ssh remote command builders', () => { tryCreateInstallLockCommand(windows, 'C:/Users/me/.orca-remote/relay/.install-lock') ) - expect(script).toContain('$ErrorActionPreference = "Stop"; try {') + expect(script).toContain('$stream = $null; try {') expect(script).toContain("} catch { 'BUSY' }") expect(script).not.toContain('}; catch') }) + it('computes install-lock age on the remote host clock', () => { + const posixCommand = lockAgeSecondsCommand(posix, '/home/me/.orca-remote/relay/.install-lock') + const windowsScript = decodePowerShellCommand( + lockAgeSecondsCommand(windows, 'C:/Users/me/.orca-remote/relay/.install-lock') + ) + + expect(posixCommand).toContain('date +%s') + expect(posixCommand).toContain('echo "$age"') + expect(windowsScript).toContain('[DateTimeOffset]::UtcNow.ToUnixTimeSeconds()') + expect(windowsScript).toContain('Write-Output ($now - $mtime)') + }) + + it('serializes stale recovery with unbounded numbered sibling claims', () => { + const posixCommand = tryStealInstallLockCommand( + posix, + '/home/me/.orca-remote/relay/.install-lock', + 20 * 60 + ) + const windowsScript = decodePowerShellCommand( + tryStealInstallLockCommand(windows, 'C:/Users/me/.orca-remote/relay/.install-lock', 20 * 60) + ) + + expect(posixCommand).toContain('.install-lock') + expect(posixCommand).toContain('.install-lock.steal') + expect(posixCommand).toContain('steal_generation') + expect(posixCommand).toContain('mtime=${lock_key%%:*}') + expect(posixCommand).toContain('-gt 1200') + expect(posixCommand).toContain('current_mtime') + expect(posixCommand).toContain('steal_generation + 1') + expect(posixCommand).not.toContain('.next.') + expect(posixCommand).toContain('trap') + expect(windowsScript).toContain('$lock.steal') + expect(windowsScript).toContain('$stealGeneration++') + expect(windowsScript).toContain('-gt 1200') + expect(windowsScript).toContain('$currentIdentity -eq $lockIdentity') + expect(windowsScript).toContain('[System.IO.FileMode]::CreateNew') + expect(windowsScript).not.toContain('.next.') + expect(windowsScript).toContain('finally') + }) + + it.runIf(powerShellExecutable)( + 'emits a parseable Windows stale-lock recovery command', + () => { + const script = decodePowerShellCommand( + tryStealInstallLockCommand( + windows, + 'C:/Users/orca-missing/.orca-remote/relay/.install-lock', + 20 * 60 + ) + ) + const result = spawnSync( + powerShellExecutable!, + ['-NoProfile', '-NonInteractive', '-Command', script], + { encoding: 'utf8' } + ) + + expect(result.status, result.stderr).toBe(0) + expect(result.stdout.trim()).toBe('BUSY') + }, + 15_000 + ) + + it.runIf(powerShell51Executable)( + 'lets only one Windows PowerShell 5.1 caller acquire an install lock', + async () => { + const root = mkdtempSync(join(tmpdir(), 'orca-install-lock-windows-race-')) + try { + const lockPath = join(root, '.install-lock') + const script = decodePowerShellCommand(tryCreateInstallLockCommand(windows, lockPath)) + const outputs = await Promise.all( + Array.from({ length: 16 }, () => runPowerShellCommand(powerShell51Executable!, script)) + ) + + expect(outputs.filter((output) => output.trim().endsWith('OK'))).toHaveLength(1) + expect(statSync(lockPath).isDirectory()).toBe(true) + expect(statSync(join(lockPath, '.owner')).isFile()).toBe(true) + } finally { + rmSync(root, { recursive: true, force: true }) + } + }, + 30_000 + ) + + it.runIf(powerShell51Executable)( + 'recovers a stale Windows lock past abandoned steal generations', + async () => { + const root = mkdtempSync(join(tmpdir(), 'orca-install-lock-windows-stale-')) + try { + const lockPath = join(root, '.install-lock') + mkdirSync(lockPath) + const staleDate = new Date(Date.now() - 60 * 60_000) + utimesSync(lockPath, staleDate, staleDate) + for (let i = 0; i < 12; i++) { + const orphan = `${lockPath}.steal.${i}` + mkdirSync(orphan) + utimesSync(orphan, staleDate, staleDate) + } + const script = decodePowerShellCommand( + tryStealInstallLockCommand(windows, lockPath, 20 * 60) + ) + + const output = await runPowerShellCommand(powerShell51Executable!, script) + + expect(output.trim()).toBe('OK') + expect(statSync(lockPath).isDirectory()).toBe(true) + expect(statSync(join(lockPath, '.owner')).isFile()).toBe(true) + expect(readdirSync(root).filter((name) => name.includes('.steal.'))).toHaveLength(0) + expect(readdirSync(root).filter((name) => name.includes('.tombstone.'))).toHaveLength(0) + } finally { + rmSync(root, { recursive: true, force: true }) + } + }, + 15_000 + ) + + it.runIf(powerShell51Executable)( + 'lets only one Windows PowerShell 5.1 caller replace a stale lock', + async () => { + const root = mkdtempSync(join(tmpdir(), 'orca-install-lock-windows-steal-race-')) + try { + const lockPath = join(root, '.install-lock') + mkdirSync(lockPath) + const staleDate = new Date(Date.now() - 60 * 60_000) + utimesSync(lockPath, staleDate, staleDate) + const script = decodePowerShellCommand( + tryStealInstallLockCommand(windows, lockPath, 20 * 60) + ) + + const outputs = await Promise.all( + Array.from({ length: 16 }, () => runPowerShellCommand(powerShell51Executable!, script)) + ) + + expect(outputs.filter((output) => output.trim().endsWith('OK'))).toHaveLength(1) + expect(statSync(lockPath).isDirectory()).toBe(true) + expect(statSync(join(lockPath, '.owner')).isFile()).toBe(true) + expect(readdirSync(root).filter((name) => name.includes('.tombstone.'))).toHaveLength(0) + } finally { + rmSync(root, { recursive: true, force: true }) + } + }, + 30_000 + ) + + it.runIf(powerShellExecutable)( + 'keeps a new Windows lock visible to the previous directory-only GC probe', + async () => { + const root = mkdtempSync(join(tmpdir(), 'orca-install-lock-windows-compat-')) + try { + const lockPath = join(root, '.install-lock') + const acquire = decodePowerShellCommand(tryCreateInstallLockCommand(windows, lockPath)) + const legacyProbe = decodePowerShellCommand(probeDirectoryExistsCommand(windows, lockPath)) + + await expect(runPowerShellCommand(powerShellExecutable!, acquire)).resolves.toMatch(/OK/) + await expect(runPowerShellCommand(powerShellExecutable!, legacyProbe)).resolves.toMatch( + /LOCKED/ + ) + } finally { + rmSync(root, { recursive: true, force: true }) + } + }, + 15_000 + ) + + it.runIf(process.platform !== 'win32')( + 'recovers and cleans more than eight orphaned numbered steal claims', + () => { + const root = mkdtempSync(join(tmpdir(), 'orca-install-lock-')) + try { + const lockDir = join(root, '.install-lock') + mkdirSync(lockDir) + const staleDate = new Date(Date.now() - 60 * 60_000) + utimesSync(lockDir, staleDate, staleDate) + const lockMtimeSeconds = Math.floor(statSync(lockDir).mtimeMs / 1000) + for (let i = 0; i < 12; i++) { + const orphan = `${lockDir}.steal.${i}` + mkdirSync(orphan) + utimesSync(orphan, staleDate, staleDate) + } + + const command = tryStealInstallLockCommand(posix, lockDir, 20 * 60) + const output = execFileSync('/bin/sh', ['-c', command], { encoding: 'utf8' }) + + expect(output.trim()).toBe('OK') + expect(existsSync(lockDir)).toBe(true) + expect(readdirSync(root).filter((name) => name.includes('.steal.'))).toHaveLength(0) + expect(Math.floor(statSync(lockDir).mtimeMs / 1000)).toBeGreaterThan(lockMtimeSeconds) + } finally { + rmSync(root, { recursive: true, force: true }) + } + } + ) + + it.runIf(process.platform !== 'win32')( + 'lets only one POSIX caller move and recreate a stale install lock', + async () => { + const root = mkdtempSync(join(tmpdir(), 'orca-install-lock-race-')) + try { + const lockDir = join(root, '.install-lock') + mkdirSync(lockDir) + const staleDate = new Date(Date.now() - 60 * 60_000) + utimesSync(lockDir, staleDate, staleDate) + const command = tryStealInstallLockCommand(posix, lockDir, 20 * 60) + const outputs = await Promise.all( + Array.from({ length: 64 }, () => runShellCommand(command)) + ) + const okCount = outputs.filter((output) => output.trim().endsWith('OK')).length + + expect(okCount).toBe(1) + expect(existsSync(lockDir)).toBe(true) + expect(readdirSync(root).some((name) => name.includes('.tombstone'))).toBe(false) + } finally { + rmSync(root, { recursive: true, force: true }) + } + } + ) + it('makes Windows remote directory changes fail before running scoped commands', () => { const scopedCommand = decodePowerShellCommand( commandInRemoteDirectory(windows, 'C:/Users/me/.orca-remote/relay-0.1.0', "'READY'") diff --git a/src/main/ssh/ssh-remote-commands.ts b/src/main/ssh/ssh-remote-commands.ts index 339f4aef594..b70761f8d54 100644 --- a/src/main/ssh/ssh-remote-commands.ts +++ b/src/main/ssh/ssh-remote-commands.ts @@ -45,6 +45,24 @@ export function removeRemoteTreeCommand(host: RemoteHostPlatform, remotePath: st ) } +export function moveRemoteTreeCommand( + host: RemoteHostPlatform, + sourcePath: string, + destinationPath: string +): string { + if (!isWindowsRemoteHost(host)) { + return `mv ${shellEscape(sourcePath)} ${shellEscape(destinationPath)} 2>&1 && echo MOVED || echo BUSY` + } + return powerShellCommand( + [ + 'try {', + `Move-Item -LiteralPath ${powerShellLiteral(sourcePath)} -Destination ${powerShellLiteral(destinationPath)} -ErrorAction Stop`, + "'MOVED'", + `} catch { 'BUSY' }` + ].join('; ') + ) +} + export function writeRemoteEmptyFileCommand(host: RemoteHostPlatform, remotePath: string): string { if (!isWindowsRemoteHost(host)) { return `touch ${shellEscape(remotePath)}` @@ -81,36 +99,6 @@ export function probeRelayInstalledCommand( ) } -export function acquireInstallLockParentCommand( - host: RemoteHostPlatform, - remoteRelayDir: string -): string { - return makeRemoteDirectoryCommand(host, remoteRelayDir) -} - -export function tryCreateInstallLockCommand(host: RemoteHostPlatform, lockDir: string): string { - if (!isWindowsRemoteHost(host)) { - return `mkdir ${shellEscape(lockDir)} 2>&1 && echo OK || echo BUSY` - } - // New-Item has no -LiteralPath parameter; using it breaks stock Windows PowerShell. - return powerShellCommand( - `$ErrorActionPreference = "Stop"; try { $null = New-Item -ItemType Directory -Path ${powerShellLiteral(lockDir)}; 'OK' } catch { 'BUSY' }` - ) -} - -export function lockMtimeEpochCommand(host: RemoteHostPlatform, lockDir: string): string { - if (!isWindowsRemoteHost(host)) { - return `stat -c %Y ${shellEscape(lockDir)} 2>/dev/null || stat -f %m ${shellEscape(lockDir)} 2>/dev/null || echo` - } - return powerShellCommand( - [ - `$item = Get-Item -LiteralPath ${powerShellLiteral(lockDir)} -ErrorAction Stop`, - '$dto = [DateTimeOffset]$item.LastWriteTimeUtc', - 'Write-Output $dto.ToUnixTimeSeconds()' - ].join('; ') - ) -} - export function listRelayBaseDirsCommand(host: RemoteHostPlatform, baseDir: string): string { if (!isWindowsRemoteHost(host)) { return `ls -1 ${shellEscape(baseDir)} 2>/dev/null || true` @@ -155,9 +143,9 @@ export function relayLivenessProbeCommand( ): string { if (!isWindowsRemoteHost(host)) { return ( - `for f in ${shellEscape(dir)}/relay-*.sock ${shellEscape(dir)}/relay.sock; do ` + - `[ -S "$f" ] && echo ALIVE && break; ` + - 'done; true' + `state=DEAD; for f in ${shellEscape(dir)}/relay-*.sock ${shellEscape(dir)}/relay.sock; do ` + + `[ -S "$f" ] && state=ALIVE && break; ` + + 'done; echo "$state"' ) } if (!windowsOptions) { diff --git a/src/main/ssh/ssh-remote-node-resolution.ts b/src/main/ssh/ssh-remote-node-resolution.ts index 01d6a0c7d48..a5041ed158d 100644 --- a/src/main/ssh/ssh-remote-node-resolution.ts +++ b/src/main/ssh/ssh-remote-node-resolution.ts @@ -122,6 +122,7 @@ true if (options?.rethrowSessionLimitErrors && isSshSessionLimitError(err)) { throw err } + throwIfAborted(options) // Fall through to login shell. } return null @@ -167,6 +168,7 @@ async function tryResolveViaLoginShell( if (options?.rethrowSessionLimitErrors && isSshSessionLimitError(err)) { throw err } + throwIfAborted(options) // Fall through. } return null @@ -191,6 +193,7 @@ async function nodeMeetsVersionRequirement( if (options?.rethrowSessionLimitErrors && isSshSessionLimitError(err)) { throw err } + throwIfAborted(options) // Binary missing or fails to run — not usable. return false } @@ -240,6 +243,7 @@ async function resolveRemoteWindowsNodePath( if (options?.rethrowSessionLimitErrors && isSshSessionLimitError(err)) { throw err } + throwIfAborted(options) // Fall through to the shared error below. } @@ -262,6 +266,7 @@ async function windowsNodeMeetsVersionRequirement( if (options?.rethrowSessionLimitErrors && isSshSessionLimitError(err)) { throw err } + throwIfAborted(options) return false } } diff --git a/src/main/ssh/ssh-remote-platform-detection.ts b/src/main/ssh/ssh-remote-platform-detection.ts index f72b5384335..44c31e617e4 100644 --- a/src/main/ssh/ssh-remote-platform-detection.ts +++ b/src/main/ssh/ssh-remote-platform-detection.ts @@ -11,30 +11,38 @@ import { powerShellCommand } from './ssh-remote-powershell' const PLATFORM_PROBE_MARKER = '__ORCA_REMOTE_PLATFORM__' export async function detectRemoteHostPlatform( - conn: SshConnection + conn: SshConnection, + options?: { signal?: AbortSignal } ): Promise { - const unamePlatform = await detectUnamePlatform(conn) + const unamePlatform = await detectUnamePlatform(conn, options?.signal) if (unamePlatform) { return getRemoteHostPlatform(unamePlatform) } - const windowsPlatform = await detectWindowsPlatform(conn) + const windowsPlatform = await detectWindowsPlatform(conn, options?.signal) return windowsPlatform ? getRemoteHostPlatform(windowsPlatform) : null } -async function detectUnamePlatform(conn: SshConnection): Promise { +async function detectUnamePlatform( + conn: SshConnection, + signal?: AbortSignal +): Promise { try { - const output = await execCommand( - conn, - // Why: Remote startup output may omit its trailing newline and must not absorb the marker. - `printf '\\n%s ' '${PLATFORM_PROBE_MARKER}'; uname -sm` - ) + // Why: Remote startup output may omit its trailing newline and must not absorb the marker. + const command = `printf '\\n%s ' '${PLATFORM_PROBE_MARKER}'; uname -sm` + const output = signal + ? await execCommand(conn, command, { signal }) + : await execCommand(conn, command) return parseRemotePlatformOutput(output) } catch { + signal?.throwIfAborted() return null } } -async function detectWindowsPlatform(conn: SshConnection): Promise { +async function detectWindowsPlatform( + conn: SshConnection, + signal?: AbortSignal +): Promise { try { const script = [ '$arch = $env:PROCESSOR_ARCHITECTURE', @@ -43,9 +51,13 @@ async function detectWindowsPlatform(conn: SshConnection): Promise arg.startsWith('ControlPath='))).toBe(true) + expect(args).toContain('ControlPersist=300') +} + type EventedProcess = EventEmitter & { stdin: EventEmitter & { write: ReturnType @@ -360,9 +370,7 @@ describe('spawnSystemSsh', () => { }) }) - expect(args).toContain('ControlMaster=auto') - expect(args.some((arg) => arg.startsWith('ControlPath='))).toBe(true) - expect(args).toContain('ControlPersist=300') + expectOrcaControlMasterArgs(args) expect(args).not.toContain('-S') }) @@ -374,9 +382,7 @@ describe('spawnSystemSsh', () => { }) }) - expect(args).toContain('ControlMaster=auto') - expect(args.some((arg) => arg.startsWith('ControlPath='))).toBe(true) - expect(args).toContain('ControlPersist=300') + expectOrcaControlMasterArgs(args) expect(args).not.toContain('-S') }) @@ -387,9 +393,7 @@ describe('spawnSystemSsh', () => { }) }) - expect(args).toContain('ControlMaster=auto') - expect(args.some((arg) => arg.startsWith('ControlPath='))).toBe(true) - expect(args).toContain('ControlPersist=300') + expectOrcaControlMasterArgs(args) expect(args).not.toContain('-S') }) @@ -414,9 +418,7 @@ describe('spawnSystemSsh', () => { resolvedConfig: createResolvedConfig() }) - expect(args).toContain('ControlMaster=auto') - expect(args.some((arg) => arg.startsWith('ControlPath='))).toBe(true) - expect(args).toContain('ControlPersist=300') + expectOrcaControlMasterArgs(args) expect(args).not.toContain('-S') }) @@ -432,10 +434,11 @@ describe('spawnSystemSsh', () => { it('adds keepalive options to Orca-owned ControlMaster connections', () => { const args = buildSshArgs(createTarget(), { resolvedConfig: createResolvedConfig() }) - expect(args).toContain('ControlMaster=auto') - expect(args).toContain('ControlPersist=300') - expect(args).toContain('ServerAliveInterval=15') - expect(args).toContain('ServerAliveCountMax=3') + expectOrcaControlMasterArgs(args) + if (process.platform !== 'win32') { + expect(args).toContain('ServerAliveInterval=15') + expect(args).toContain('ServerAliveCountMax=3') + } }) it('spawns a remote command through the system ssh target', () => { @@ -507,6 +510,15 @@ describe('spawnSystemSsh', () => { expect(mockProc.stdin.end).toHaveBeenCalledWith('contents') }) + it('marks a system command channel when local teardown is requested', () => { + const channel = spawnSystemSshCommand(createTarget(), 'npm install') + + channel.close() + + expect(channel._closeRequested).toBe(true) + expect(mockProc.kill).toHaveBeenCalledWith('SIGTERM') + }) + it('removes wrapped process listeners after command close', () => { const proc = createEventedProcess() spawnMock.mockReturnValue(proc) diff --git a/src/main/ssh/system-ssh-command.ts b/src/main/ssh/system-ssh-command.ts index 1f0ce5ed6df..eab96245ba5 100644 --- a/src/main/ssh/system-ssh-command.ts +++ b/src/main/ssh/system-ssh-command.ts @@ -17,6 +17,7 @@ export type SystemSshProcess = { export type SystemSshCommandChannel = ClientChannel & { _process?: ChildProcess + _closeRequested?: boolean } type SystemSshCommandOptions = SshExecOptions & SystemSshBuildArgsOptions @@ -103,12 +104,14 @@ function wrapCommandProcess(proc: ChildProcess): SystemSshCommandChannel { stdin: NodeJS.WritableStream stderr: NodeJS.ReadableStream _process?: ChildProcess + _closeRequested?: boolean close: () => void } mutableChannel.stdin = proc.stdin! mutableChannel.stderr = proc.stderr! mutableChannel._process = proc mutableChannel.close = () => { + mutableChannel._closeRequested = true try { proc.kill('SIGTERM') } catch { diff --git a/src/main/ssh/system-ssh-operation-lifecycle.ts b/src/main/ssh/system-ssh-operation-lifecycle.ts index 76a2ae3fb12..c9c4424325b 100644 --- a/src/main/ssh/system-ssh-operation-lifecycle.ts +++ b/src/main/ssh/system-ssh-operation-lifecycle.ts @@ -100,7 +100,13 @@ export async function awaitWithSystemSshAbort( // children to emit close after we've already signaled them. abortChildren() suppressLateOperationError = true - abortReject?.(createAbortError()) + abortReject?.( + Object.assign(createAbortError(), { + // The child was signaled, but this fast abort path intentionally does + // not wait for process exit; callers holding remote locks must retain them. + sshChannelCloseConfirmed: false + }) + ) } signal.addEventListener('abort', abort, { once: true }) if (signal.aborted) { diff --git a/src/relay/pty-handler.test.ts b/src/relay/pty-handler.test.ts index efab6bce51f..8de7ec8051b 100644 --- a/src/relay/pty-handler.test.ts +++ b/src/relay/pty-handler.test.ts @@ -181,6 +181,29 @@ describe('PtyHandler', () => { expect(handler.activePtyCount).toBe(1) }) + it('normalizes a missing native binding as degraded node-pty availability', async () => { + mockPtySpawn.mockImplementationOnce(() => { + throw new Error( + 'Failed to load native module: conpty.node, checked: build/Release, prebuilds/win32-x64' + ) + }) + + await expect(dispatcher.callRequest('pty.spawn', {})).rejects.toThrow( + 'node-pty is not available on this remote host' + ) + expect(handler.activePtyCount).toBe(0) + }) + + it('preserves unrelated node-pty spawn failures', async () => { + mockPtySpawn.mockImplementationOnce(() => { + throw new Error('File not found: missing-shell.exe') + }) + + await expect(dispatcher.callRequest('pty.spawn', {})).rejects.toThrow( + 'File not found: missing-shell.exe' + ) + }) + it('atomically caps concurrent PTY spawn admission', async () => { const results = await Promise.allSettled( Array.from({ length: MAX_RELAY_PTY_SESSIONS + 1 }, () => diff --git a/src/relay/pty-handler.ts b/src/relay/pty-handler.ts index c1de6809c15..fb0d38bf53c 100644 --- a/src/relay/pty-handler.ts +++ b/src/relay/pty-handler.ts @@ -1,6 +1,8 @@ /* oxlint-disable max-lines */ import type { IPty } from 'node-pty' import type * as NodePty from 'node-pty' +import { existsSync } from 'node:fs' +import { join } from 'node:path' import { resolveWindowsGitBashShellPath } from '../main/git-bash' import { WINDOWS_GIT_BASH_SHELL } from '../shared/windows-terminal-shell' import type { RelayDispatcher, RequestContext } from './dispatcher' @@ -38,21 +40,11 @@ import { import { isTuiAgent } from '../shared/tui-agent-config' import { forceKillPosixPtyProcessGroups } from '../main/pty/posix-pty-process-groups' -// Why: node-pty is a native addon that may not be installed on the remote. -// Dynamic import keeps the require() lazy so loadPty() returns null gracefully -// when the native module is unavailable. The static type import lets vitest -// intercept it in tests. -let ptyModule: typeof NodePty | null = null -async function loadPty(): Promise { - if (ptyModule) { - return ptyModule - } - try { - ptyModule = await import('node-pty') - return ptyModule - } catch { - return null - } +function isMissingNodePtyNativeBinding(error: unknown): boolean { + return ( + error instanceof Error && + /Failed to load native module: (?:conpty|pty)\.node(?:,|$)/.test(error.message) + ) } type ManagedPty = { @@ -296,6 +288,9 @@ export class PtyHandler { private pendingCreationDrainResolvers = new Set<() => void>() private worktreeRemovalCoordinator: RelayPtyWorktreeRemovalCoordinator | null = null private disposePromise: Promise | null = null + private ptyModule: typeof NodePty | null = null + private ptyModuleLoadPromise: Promise | null = null + private reloadPtyModuleFromDisk = false // Why: external observers need to drop per-pane state when a PTY exits. // Today the relay composes multiple consumers (hook-server cache eviction // and plugin-overlay dir cleanup) into a single callback at the call site @@ -316,6 +311,55 @@ export class PtyHandler { this.registerHandlers() } + private async loadPty(): Promise { + if (this.ptyModule) { + return this.ptyModule + } + if (this.ptyModuleLoadPromise) { + return this.ptyModuleLoadPromise + } + this.ptyModuleLoadPromise = this.loadPtyUncached() + try { + return await this.ptyModuleLoadPromise + } finally { + this.ptyModuleLoadPromise = null + } + } + + private async loadPtyUncached(): Promise { + if (!this.reloadPtyModuleFromDisk) { + try { + this.ptyModule = await import('node-pty') + return this.ptyModule + } catch { + this.reloadPtyModuleFromDisk = true + } + } + // Why: the relay is launched from its install dir today, but module + // resolution must remain tied to the deployed bundle rather than cwd. + const moduleEntry = join(__dirname, 'node_modules', 'node-pty', 'lib', 'index.js') + if (!existsSync(moduleEntry)) { + return null + } + try { + this.ptyModule = require(moduleEntry) as typeof NodePty + return this.ptyModule + } catch { + return null + } + } + + private invalidatePtyModuleAfterBindingFailure(): void { + this.ptyModule = null + this.reloadPtyModuleFromDisk = true + const moduleRoot = join(__dirname, 'node_modules', 'node-pty') + for (const cachedPath of Object.keys(require.cache)) { + if (isPathInsideOrEqual(moduleRoot, cachedPath)) { + delete require.cache[cachedPath] + } + } + } + setGraceTimeMs(graceTimeMs: number): void { this.graceTimeMs = Math.max(0, Math.floor(graceTimeMs)) } @@ -741,7 +785,7 @@ export class PtyHandler { params: Record, context?: RequestContext ): Promise<{ id: string }> { - const pty = await loadPty() + const pty = await this.loadPty() if (!pty) { throw new Error('node-pty is not available on this remote host') } @@ -814,17 +858,28 @@ export class PtyHandler { // includes Homebrew, nvm, and user-installed CLIs (claude, codex, gh). // When overlays are injected, the launch wrapper keeps those paths after // user startup files re-export their defaults. - const term = pty.spawn(shell, shellLaunch.args, { - // Why: node-pty overwrites env.TERM with `name`; keep caller-selected - // terminal identities instead of losing them at the final spawn boundary. - name: spawnEnv.TERM ?? 'xterm-256color', - cols, - rows, - cwd, - // Why: relay shells inherit process.env; never let an ambient Orca marker - // enable shell-ready behavior unless this spawn explicitly requested it. - env: { ...spawnEnv, ORCA_SHELL_READY_MARKER: '0', ...shellLaunch.env } - }) + let term: IPty + try { + term = pty.spawn(shell, shellLaunch.args, { + // Why: node-pty overwrites env.TERM with `name`; keep caller-selected + // terminal identities instead of losing them at the final spawn boundary. + name: spawnEnv.TERM ?? 'xterm-256color', + cols, + rows, + cwd, + // Why: relay shells inherit process.env; never let an ambient Orca marker + // enable shell-ready behavior unless this spawn explicitly requested it. + env: { ...spawnEnv, ORCA_SHELL_READY_MARKER: '0', ...shellLaunch.env } + }) + } catch (error) { + // Why: Windows node-pty loads conpty.node only on first spawn, after the + // wrapper import succeeded. Keep that late failure on the degraded path. + if (isMissingNodePtyNativeBinding(error)) { + this.invalidatePtyModuleAfterBindingFailure() + throw new Error('node-pty is not available on this remote host') + } + throw error + } // Why: capture the renderer-supplied paneKey on the managed entry so the // exit listener can evict per-pane caches without the relay needing a @@ -1215,7 +1270,7 @@ export class PtyHandler { } private async reviveEntry(entry: SerializedPtyEntry): Promise { - const ptyMod = await loadPty() + const ptyMod = await this.loadPty() if (!ptyMod) { return } diff --git a/src/relay/subprocess-test-utils.ts b/src/relay/subprocess-test-utils.ts index 69a6e607a06..b721f17968f 100644 --- a/src/relay/subprocess-test-utils.ts +++ b/src/relay/subprocess-test-utils.ts @@ -25,7 +25,7 @@ export type RelayProcess = { export function spawnRelay( entryPath: string, args: string[] = [], - options: Pick = {} + options: Pick = {} ): RelayProcess { const proc = spawn('node', [entryPath, ...args], { stdio: ['pipe', 'pipe', 'pipe'], diff --git a/src/relay/subprocess.test.ts b/src/relay/subprocess.test.ts index ee20461dd68..c772a8e514a 100644 --- a/src/relay/subprocess.test.ts +++ b/src/relay/subprocess.test.ts @@ -1,5 +1,13 @@ import { afterAll, beforeAll, describe, expect, it, afterEach } from 'vitest' -import { existsSync, mkdtempSync, readFileSync, unlinkSync, writeFileSync } from 'node:fs' +import { + copyFileSync, + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + unlinkSync, + writeFileSync +} from 'node:fs' import { rm } from 'node:fs/promises' import * as path from 'node:path' import { tmpdir } from 'node:os' @@ -46,9 +54,14 @@ afterAll(async () => { } }) -function spawn(args: string[] = [], env?: NodeJS.ProcessEnv): RelayProcess { +function spawnRelayEntry( + entryPath: string, + args: string[] = [], + env?: NodeJS.ProcessEnv +): RelayProcess { let relayArgs = args if (!args.includes('--sock-path')) { + // Why: Windows relays require a named pipe; filesystem socket paths fail with EACCES. const socketDir = mkdtempSync(path.join(tmpdir(), 'relay-sock-')) spawnedSocketDirs.push(socketDir) relayArgs = [ @@ -59,7 +72,11 @@ function spawn(args: string[] = [], env?: NodeJS.ProcessEnv): RelayProcess { path.join(socketDir, 'agent-hooks') ] } - return spawnRelay(relayEntry, relayArgs, env ? { env } : undefined) + return spawnRelay(entryPath, relayArgs, env ? { env } : undefined) +} + +function spawn(args: string[] = [], env?: NodeJS.ProcessEnv): RelayProcess { + return spawnRelayEntry(relayEntry, args, env) } function waitForChildExit( @@ -75,6 +92,22 @@ function waitForChildExit( }) } +function writeMockNodePty(root: string, source: string, withPackageEntry = false): void { + const nodePtyDir = path.join(root, 'node_modules', 'node-pty') + const libDir = path.join(nodePtyDir, 'lib') + mkdirSync(libDir, { recursive: true }) + if (withPackageEntry) { + writeFileSync(path.join(nodePtyDir, 'package.json'), '{"main":"lib/index.js"}\n') + } + writeFileSync(path.join(libDir, 'index.js'), source) +} + +const WORKING_NODE_PTY_MODULE = `module.exports = { spawn() { return { + pid: process.pid, + process: 'mock-shell', + onData() {}, onExit() {}, write() {}, resize() {}, kill() {}, clear() {} +} } }\n` + describe('Subprocess: Relay entry point', () => { let relay: RelayProcess | null = null let tmpDir: string @@ -103,6 +136,50 @@ describe('Subprocess: Relay entry point', () => { expect(readFileSync(relayEntry, 'utf8')).not.toContain('.toReversed(') }) + it('loads node-pty after an in-place dependency repair without restarting', async () => { + tmpDir = mkdtempSync(path.join(tmpdir(), 'relay-native-repair-')) + const repairedRelayEntry = path.join(tmpDir, 'relay.js') + copyFileSync(relayEntry, repairedRelayEntry) + + relay = spawnRelayEntry(repairedRelayEntry) + await relay.sentinelReceived + + const failedId = relay.send('pty.spawn', { cols: 80, rows: 24 }) + const failed = await relay.waitForResponse(failedId) + expect(failed.error?.message).toContain('node-pty is not available') + + writeMockNodePty(tmpDir, WORKING_NODE_PTY_MODULE) + + const repairedId = relay.send('pty.spawn', { cols: 80, rows: 24 }) + const repaired = await relay.waitForResponse(repairedId) + expect(repaired.error).toBeUndefined() + expect(repaired.result).toMatchObject({ id: 'pty-1' }) + }, 10_000) + + it('reloads node-pty after a late native binding failure without restarting', async () => { + tmpDir = mkdtempSync(path.join(tmpdir(), 'relay-native-late-repair-')) + const repairedRelayEntry = path.join(tmpDir, 'relay.js') + copyFileSync(relayEntry, repairedRelayEntry) + writeMockNodePty( + tmpDir, + `module.exports = { spawn() { throw new Error('Failed to load native module: conpty.node, checked: prebuilds/win32-x64') } }\n`, + true + ) + + relay = spawnRelayEntry(repairedRelayEntry) + await relay.sentinelReceived + + const failedId = relay.send('pty.spawn', { cols: 80, rows: 24 }) + const failed = await relay.waitForResponse(failedId) + expect(failed.error?.message).toContain('node-pty is not available') + + writeMockNodePty(tmpDir, WORKING_NODE_PTY_MODULE, true) + const repairedId = relay.send('pty.spawn', { cols: 80, rows: 24 }) + const repaired = await relay.waitForResponse(repairedId) + expect(repaired.error).toBeUndefined() + expect(repaired.result).toMatchObject({ id: 'pty-2' }) + }, 10_000) + it('responds to fs.stat over stdin/stdout', async () => { tmpDir = mkdtempSync(path.join(tmpdir(), 'relay-sub-')) writeFileSync(path.join(tmpDir, 'test.txt'), 'hello') From 8ec050a875f9a482dfebc576d8c3c76c06ab059c Mon Sep 17 00:00:00 2001 From: fsdwen Date: Fri, 17 Jul 2026 12:39:13 +0800 Subject: [PATCH 22/24] =?UTF-8?q?fix(i18n):=20=E4=BF=AE=E5=A4=8D=20zh.json?= =?UTF-8?q?=20=E5=88=86=E9=A1=B5=E7=BF=BB=E8=AF=91=E9=94=99=E8=AF=AF?= =?UTF-8?q?=E5=92=8C=209=20=E5=A4=84=E6=9C=AA=E7=BF=BB=E8=AF=91=E6=96=87?= =?UTF-8?q?=E6=A1=88=20(#8990)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 分页按钮文字修正:Next→下一页, Next page→前往下一页, Previous page→前往上一页 - 翻译 4 处 Jira 连接对话框未翻译描述文案 - 翻译 2 处 Jira 集成卡片描述(远程存储/本地存储) - 翻译 2 处文件删除确认混合场景文案 - 翻译 Grok 统一计费账户月度已用额度说明 Closes #8988 Co-authored-by: Jinjing <6427696+AmethystLiang@users.noreply.github.com> --- src/renderer/src/i18n/locales/zh.json | 28 +++++++++++++-------------- 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index d6cd49a0234..52d6e397678 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -1606,12 +1606,12 @@ "cba2a2b7fb": "Linear 项目缺少工作区上下文。", "f4374519ae": "您的首选议题源(upstream)已不再为 {{value0}} 配置。将使用 origin。", "e9139db03f": "无法隐藏 {{value0}}。", - "b73717af92": "下一步", - "0c8df28045": "下一页", + "b73717af92": "下一页", + "0c8df28045": "前往下一页", "ae859c816b": "第{{value0}}页", "cd171f3391": "...", "297a805b64": "上一页", - "6cd6b3ae6a": "上一页", + "6cd6b3ae6a": "前往上一页", "e65757a338": "分页", "37d60046e3": "打开 GitHub 合并框", "1a9ea003dc": "无法禁用自动合并", @@ -8738,8 +8738,8 @@ "e1f5e6424c": "{{value0}} 个工作区{{value1}} 已连接", "disconnect_all": "断开连接", "account_scope_prefix": "账户范围", - "2d60ec7921": "Connect a Jira Cloud site with an API token, or a self-hosted Jira with a personal access token or username and password. Credentials are sent to the selected remote runtime and stored there with runtime-supported encryption.", - "977e360b71": "Connect a Jira Cloud site with an API token, or a self-hosted Jira with a personal access token or username and password. Credentials are stored locally and encrypted when local runtime storage supports it." + "2d60ec7921": "使用 API token 连接 Jira Cloud 站点,或使用个人访问令牌或用户名和密码连接自托管 Jira。凭据会发送到所选远程运行时并以运行时支持的加密方式存储。", + "977e360b71": "使用 API token 连接 Jira Cloud 站点,或使用个人访问令牌或用户名和密码连接自托管 Jira。凭据存储在本地,本地运行时存储支持时会进行加密。" } } } @@ -9099,9 +9099,9 @@ "b7e2d9f0a3": "与终端中 grok /usage 屏幕显示的每周额度百分比相同。", "c6d1a8f4e2": "{{when}} 重置", "e6dadc1e2b": "Monthly usage", - "75e396bf42": "Included monthly usage for Grok unified-billing accounts.", - "b36fa2c908": "Signed in. Orca reads the Grok CLI session stored on disk.", - "f08c41de73": "Session expired — run grok on the computer running Orca and wait for it to start. If prompted, complete sign-in, then click Refresh usage. No chat message is needed." + "75e396bf42": "Grok 统一计费账户的月度已用额度。", + "b36fa2c908": "已登录。Orca 读取存储在磁盘上的 Grok CLI 会话。", + "f08c41de73": "会话已过期 — 在运行 Orca 的计算机上运行 grok 并等待其启动。如果提示,请完成登录,然后点击刷新使用量。无需发送聊天消息。" }, "AppearanceWindowSidebarSection": { "usagePercentageDisplayUsed": "已用", @@ -10032,8 +10032,8 @@ "8b8ee9d22f": "无法确定哪个主机拥有此文件。请检查工作区连接后重试。", "af1270b90d": "永久删除 {{count}} 项?", "dd029aa5cd": "这会永久删除远程主机上所选的各项及任何目录内容。此操作无法撤销。", - "77fdc36183": "Delete {{count}} items?", - "fca915a67a": "Remote items are permanently deleted and cannot be undone. Local items move to the {{value0}}." + "77fdc36183": "删除 {{count}} 个项目?", + "fca915a67a": "远程项目将永久删除且无法撤销。本地项目将移至 {{value0}}。" }, "useFileExplorerHandlers": { "32cd9fd991": "无法打开符号链接目标" @@ -12764,7 +12764,7 @@ "e176f9d0c5": "Jira Cloud 站点 URL", "d785c42b8b": "使用 Jira Cloud 站点 URL、Atlassian 邮箱和 API token 来浏览议题。", "8388bdea2b": "连接 Jira 站点", - "2e2b69e48e": "Use a self-hosted Jira base URL and a personal access token to browse issues.", + "2e2b69e48e": "使用自托管 Jira 的基础 URL 和个人访问令牌来浏览议题。", "b67e919bd5": "Jira instance type", "17787d6e4b": "Atlassian Cloud", "bc7a831773": "Self-hosted", @@ -12772,15 +12772,15 @@ "cbc27fa599": "https://jira.example.com", "730d973bae": "Personal access token", "8b9c7b9e7b": "Jira personal access token", - "ccfb086d3e": "Create a personal access token in your Jira profile under Personal Access Tokens.", - "1d947a07ab": "Use a self-hosted Jira base URL, username, and password to browse issues.", + "ccfb086d3e": "在 Jira 个人资料中的 Personal Access Tokens 下创建一个个人访问令牌。", + "1d947a07ab": "使用自托管 Jira 的基础 URL、用户名和密码来浏览议题。", "f49708c369": "Jira authentication method", "84a810dd0e": "Username & password", "8d1223fa5c": "Username", "be9eba0a1b": "username", "70035652d7": "Password", "c50abbf340": "Jira account password", - "d8737db691": "Use your Jira Server or Data Center account username and password." + "d8737db691": "使用你的 Jira Server 或 Data Center 账户用户名和密码。" } } }, From 13c690b05aed1b4527ede3398d09ca7d697ebde7 Mon Sep 17 00:00:00 2001 From: Jinho Choi <83327025+nasagong@users.noreply.github.com> Date: Fri, 17 Jul 2026 13:59:44 +0900 Subject: [PATCH 23/24] fix(pet): match Codex's per-frame pacing and pointer interactions for imported pets (#8730) Imported .codex-pet bundles played every animation at a flat 8 fps (~9x too fast). Mirror Codex's exact per-frame duration tables, render uneven holds as step-end keyframes, upgrade legacy-persisted pets at render, and add the Codex mascot's pointer interactions (hover, grab-and-hold on frame 0, horizontal-drag running). Hardened over several adversarial review rounds: honor explicit fps, start each row/pet from frame 0, scope drag to its pointer, and guard untrusted persisted data. Fixes #8729 Co-authored-by: nasagong --- src/main/ipc/pet-bundle.test.ts | 35 ++++ src/main/ipc/pet-bundle.ts | 44 +++-- src/main/ipc/pet.test.ts | 63 +++++++ src/main/ipc/pet.ts | 9 +- .../pet/PetOverlay.frame-durations.test.tsx | 112 +++++++++++++ .../pet/PetOverlay.pet-switch.test.tsx | 107 ++++++++++++ .../PetOverlay.pointer-interaction.test.tsx | 156 ++++++++++++++++++ .../src/components/pet/PetOverlay.tsx | 110 ++++++------ .../components/pet/pet-agent-state.test.ts | 58 ++++++- .../src/components/pet/pet-agent-state.ts | 68 ++++++-- .../pet/sprite-animation-css.test.ts | 83 ++++++++++ .../components/pet/sprite-animation-css.ts | 99 +++++++++++ .../pet/usePetPointerInteraction.test.ts | 142 ++++++++++++++++ .../pet/usePetPointerInteraction.ts | 119 +++++++++++++ src/renderer/src/components/pet/usePetUrl.ts | 10 +- src/shared/codex-pet-sprite-defaults.test.ts | 111 +++++++++++++ src/shared/codex-pet-sprite-defaults.ts | 100 +++++++++++ src/shared/types.ts | 2 + 18 files changed, 1334 insertions(+), 94 deletions(-) create mode 100644 src/renderer/src/components/pet/PetOverlay.frame-durations.test.tsx create mode 100644 src/renderer/src/components/pet/PetOverlay.pet-switch.test.tsx create mode 100644 src/renderer/src/components/pet/PetOverlay.pointer-interaction.test.tsx create mode 100644 src/renderer/src/components/pet/sprite-animation-css.test.ts create mode 100644 src/renderer/src/components/pet/sprite-animation-css.ts create mode 100644 src/renderer/src/components/pet/usePetPointerInteraction.test.ts create mode 100644 src/renderer/src/components/pet/usePetPointerInteraction.ts create mode 100644 src/shared/codex-pet-sprite-defaults.test.ts create mode 100644 src/shared/codex-pet-sprite-defaults.ts diff --git a/src/main/ipc/pet-bundle.test.ts b/src/main/ipc/pet-bundle.test.ts index f8d4f880f4b..096dd37e853 100644 --- a/src/main/ipc/pet-bundle.test.ts +++ b/src/main/ipc/pet-bundle.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it } from 'vitest' +import { applyCodexSpriteTimingDefaults } from '../../shared/codex-pet-sprite-defaults' import { CODEX_PET_ANIMATIONS, CODEX_PET_FRAME, @@ -63,6 +64,40 @@ describe('applyCodexPetDefaults', () => { expect(manifest.animations).toEqual(CODEX_PET_ANIMATIONS) }) + it('bakes uniform per-frame durations when a Codex-layout bundle pins an explicit fps', () => { + // The requested fps is baked as durations so it is honored rather than + // overridden by the timed table, and so the sprite never enters the + // no-durations legacy retiming path. + const manifest = applyCodexPetDefaults({ id: 'zippy', displayName: 'Zippy', fps: 4 }) + + expect(manifest.fps).toBe(4) + // 4 fps → 250ms per frame; idle keeps its six frames. + expect(manifest.animations?.idle).toEqual({ + row: 0, + frames: 6, + frameDurationsMs: [250, 250, 250, 250, 250, 250] + }) + }) + + it('keeps an explicit fps=8 bundle out of the legacy retiming path', () => { + // fps=8 matches the Codex default and the legacy geometry, but baking + // durations makes it a non-match so the uniform pacing is preserved. + const manifest = applyCodexPetDefaults({ id: 'octo', displayName: 'Octo', fps: 8 }) + const sprite = { + frameWidth: 192, + frameHeight: 208, + columns: 8, + rows: 9, + sheetWidth: 1536, + sheetHeight: 1872, + fps: 8, + defaultAnimation: 'idle', + animations: manifest.animations + } + expect(applyCodexSpriteTimingDefaults(sprite)).toBe(sprite) + expect(sprite.animations?.idle.frameDurationsMs).toEqual([125, 125, 125, 125, 125, 125]) + }) + it('does not override explicit Orca bundle sprite metadata', () => { const manifest = applyCodexPetDefaults({ spritesheetPath: 'custom.png', diff --git a/src/main/ipc/pet-bundle.ts b/src/main/ipc/pet-bundle.ts index 1d231ebaa21..1b701a7a105 100644 --- a/src/main/ipc/pet-bundle.ts +++ b/src/main/ipc/pet-bundle.ts @@ -1,4 +1,22 @@ import type { SpriteAnimation } from '../../shared/types' +import { + CODEX_PET_ANIMATIONS, + CODEX_PET_DEFAULT_ANIMATION, + CODEX_PET_DEFAULT_FPS, + CODEX_PET_FRAME, + CODEX_PET_SPRITESHEET_PATH, + codexAnimationsAtUniformFps +} from '../../shared/codex-pet-sprite-defaults' + +// Re-exported so main-side consumers keep their import path. The tables live +// in shared/ so the renderer can reuse the same fingerprint for upgrades. +export { + CODEX_PET_ANIMATIONS, + CODEX_PET_DEFAULT_ANIMATION, + CODEX_PET_DEFAULT_FPS, + CODEX_PET_FRAME, + CODEX_PET_SPRITESHEET_PATH +} export type PetManifestLike = { id?: string @@ -19,23 +37,6 @@ export type ResolvedPetManifest = T spritesheetPath: string } -export const CODEX_PET_SPRITESHEET_PATH = 'spritesheet.webp' -export const CODEX_PET_FRAME = { width: 192, height: 208 } as const -export const CODEX_PET_DEFAULT_ANIMATION = 'idle' -export const CODEX_PET_DEFAULT_FPS = 8 - -export const CODEX_PET_ANIMATIONS: Record = { - idle: { row: 0, frames: 6 }, - 'running-right': { row: 1, frames: 8 }, - 'running-left': { row: 2, frames: 8 }, - waving: { row: 3, frames: 4 }, - jumping: { row: 4, frames: 5 }, - failed: { row: 5, frames: 8 }, - waiting: { row: 6, frames: 6 }, - running: { row: 7, frames: 6 }, - review: { row: 8, frames: 6 } -} - function isCodexPetSpritePath(spritesheetPath: string | undefined): boolean { return spritesheetPath === undefined || /(^|[/\\])spritesheet\.webp$/i.test(spritesheetPath) } @@ -61,7 +62,14 @@ export function applyCodexPetDefaults( frame: manifest.frame ?? CODEX_PET_FRAME, fps: manifest.fps ?? CODEX_PET_DEFAULT_FPS, defaultAnimation: manifest.defaultAnimation ?? CODEX_PET_DEFAULT_ANIMATION, - animations: manifest.animations ?? CODEX_PET_ANIMATIONS + // Why: with no fps, bake Codex's intended uneven pacing; with an explicit + // fps, bake that as uniform durations so it is honored instead of being + // overridden by the timed table (and stays out of the legacy retiming path). + animations: + manifest.animations ?? + (manifest.fps === undefined + ? CODEX_PET_ANIMATIONS + : codexAnimationsAtUniformFps(manifest.fps)) } } diff --git a/src/main/ipc/pet.test.ts b/src/main/ipc/pet.test.ts index 59311df5613..5e857bb1244 100644 --- a/src/main/ipc/pet.test.ts +++ b/src/main/ipc/pet.test.ts @@ -109,4 +109,67 @@ describe('registerPetHandlers', () => { readFile(join(userDataDir, 'sidekicks', 'custom', result.id, 'spritesheet.png')) ).resolves.toEqual(sheetBytes) }) + + function webpVp8x(width: number, height: number): Buffer { + const u24 = (value: number): Buffer => + Buffer.from([value & 0xff, (value >> 8) & 0xff, (value >> 16) & 0xff]) + const payload = Buffer.concat([Buffer.from([0, 0, 0, 0]), u24(width - 1), u24(height - 1)]) + const size = Buffer.alloc(4) + size.writeUInt32LE(payload.byteLength, 0) + const riffSize = Buffer.alloc(4) + riffSize.writeUInt32LE(4 + 8 + payload.byteLength, 0) + return Buffer.concat([ + Buffer.from('RIFF'), + riffSize, + Buffer.from('WEBP'), + Buffer.from('VP8X'), + size, + payload + ]) + } + + async function writeSpriteBundle( + animations: Record + ): Promise { + const bundleDir = join(tempDir, 'durations.codex-pet') + await mkdir(bundleDir, { recursive: true }) + await writeFile( + join(bundleDir, 'pet.json'), + JSON.stringify({ + id: 'durations', + displayName: 'Durations', + spritesheetPath: 'sheet.webp', + frame: { width: 2, height: 2 }, + animations + }) + ) + await writeFile(join(bundleDir, 'sheet.webp'), webpVp8x(4, 2)) + return bundleDir + } + + it('imports a bundle whose animations declare per-frame durations', async () => { + const bundleDir = await writeSpriteBundle({ + idle: { row: 0, frames: 2, frameDurationsMs: [1680, 1920] } + }) + showOpenDialogMock.mockResolvedValue({ canceled: false, filePaths: [bundleDir] }) + + const result = (await getHandler('pet:importPetBundle')({ sender: {} })) as CustomPet + + expect(result.sprite?.animations?.idle).toEqual({ + row: 0, + frames: 2, + frameDurationsMs: [1680, 1920] + }) + }) + + it('rejects a bundle whose frame durations do not match the frame count', async () => { + const bundleDir = await writeSpriteBundle({ + idle: { row: 0, frames: 2, frameDurationsMs: [1680] } + }) + showOpenDialogMock.mockResolvedValue({ canceled: false, filePaths: [bundleDir] }) + + await expect(getHandler('pet:importPetBundle')({ sender: {} })).rejects.toThrow( + 'declares 1 frame durations but 2 frames' + ) + }) }) diff --git a/src/main/ipc/pet.ts b/src/main/ipc/pet.ts index 2a6c0e13c73..8e4b0d60ea0 100644 --- a/src/main/ipc/pet.ts +++ b/src/main/ipc/pet.ts @@ -112,7 +112,9 @@ const PetManifestSchema = z z.string().min(1).max(64), z.object({ row: z.number().int().min(0).max(256), - frames: z.number().int().positive().max(512) + frames: z.number().int().positive().max(512), + // Why: cap each hold at 60s so a bad manifest can't freeze the overlay. + frameDurationsMs: z.array(z.number().positive().max(60_000)).max(512).optional() }) ) .optional() @@ -394,6 +396,11 @@ export function registerPetHandlers(): void { `Animation "${name}" has ${anim.frames} frames but sheet only has ${columns} columns.` ) } + if (anim.frameDurationsMs && anim.frameDurationsMs.length !== anim.frames) { + throw new Error( + `Animation "${name}" declares ${anim.frameDurationsMs.length} frame durations but ${anim.frames} frames.` + ) + } } if (manifest.defaultAnimation && !manifest.animations[manifest.defaultAnimation]) { throw new Error(`defaultAnimation "${manifest.defaultAnimation}" not in animations.`) diff --git a/src/renderer/src/components/pet/PetOverlay.frame-durations.test.tsx b/src/renderer/src/components/pet/PetOverlay.frame-durations.test.tsx new file mode 100644 index 00000000000..1fb5819119a --- /dev/null +++ b/src/renderer/src/components/pet/PetOverlay.frame-durations.test.tsx @@ -0,0 +1,112 @@ +// @vitest-environment happy-dom + +import { act } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const storeState = vi.hoisted(() => ({ + agentStatusByPaneKey: {}, + agentStatusEpoch: 0, + retainedAgentsByPaneKey: {}, + petSize: 180 +})) + +vi.mock('../../store', () => ({ + useAppStore: Object.assign( + (selector: (state: typeof storeState) => T): T => { + return selector(storeState) + }, + { + getState: () => storeState + } + ) +})) + +vi.mock('./usePetUrl', () => ({ + usePetUrl: () => ({ + url: 'blob:custom-pet', + ready: true, + sprite: { + frameWidth: 192, + frameHeight: 208, + columns: 8, + rows: 9, + sheetWidth: 1536, + sheetHeight: 1872, + fps: 8, + defaultAnimation: 'idle', + animations: { + // Codex ambient idle pacing: 6.6s cycle with long bookend holds. + idle: { row: 0, frames: 6, frameDurationsMs: [1680, 660, 660, 840, 840, 1920] } + } + }, + detected: null + }) +})) + +import { PetOverlay } from './PetOverlay' + +function renderPetOverlay(): { container: HTMLDivElement; root: Root } { + const container = document.createElement('div') + document.body.appendChild(container) + const root = createRoot(container) + act(() => { + root.render() + }) + return { container, root } +} + +function installLocalStorage(): void { + const values = new Map() + Object.defineProperty(window, 'localStorage', { + configurable: true, + value: { + clear: () => values.clear(), + getItem: (key: string) => values.get(key) ?? null, + removeItem: (key: string) => values.delete(key), + setItem: (key: string, value: string) => values.set(key, value) + } + }) +} + +describe('PetOverlay per-frame sprite durations', () => { + let root: Root | null = null + let container: HTMLDivElement | null = null + + beforeEach(() => { + installLocalStorage() + }) + + afterEach(() => { + if (root) { + act(() => root?.unmount()) + } + container?.remove() + root = null + container = null + }) + + it('emits one held keyframe stop per frame instead of uniform steps()', () => { + ;({ container, root } = renderPetOverlay()) + + const css = Array.from(container.querySelectorAll('style')) + .map((style) => style.textContent ?? '') + .join('\n') + + // Cumulative stops for [1680, 660, 660, 840, 840, 1920] over 6600ms. + expect(css).toContain('0% { background-position: 0px 0px; }') + expect(css).toContain('25.4545%') + expect(css).toContain('35.4545%') + expect(css).toContain('45.4545%') + expect(css).toContain('58.1818%') + expect(css).toContain('70.9091%') + expect(css).not.toContain('to { background-position:') + + const spriteDiv = Array.from(container.querySelectorAll('div')).find( + (div) => div.style.backgroundImage !== '' + ) + expect(spriteDiv?.style.animation).toContain('6.6s') + expect(spriteDiv?.style.animation).toContain('step-end') + expect(spriteDiv?.style.animation).toContain('infinite') + }) +}) diff --git a/src/renderer/src/components/pet/PetOverlay.pet-switch.test.tsx b/src/renderer/src/components/pet/PetOverlay.pet-switch.test.tsx new file mode 100644 index 00000000000..3467119ee18 --- /dev/null +++ b/src/renderer/src/components/pet/PetOverlay.pet-switch.test.tsx @@ -0,0 +1,107 @@ +// @vitest-environment happy-dom + +import { act } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const storeState = vi.hoisted(() => ({ + agentStatusByPaneKey: {}, + agentStatusEpoch: 0, + retainedAgentsByPaneKey: {}, + petSize: 180 +})) + +// Why: a mutable pet so a re-render can simulate switching between two cached +// custom pets that both resolve the idle state to different rows. +const petUrlState = vi.hoisted(() => ({ + url: 'blob:pet-a', + idleRow: 0 +})) + +vi.mock('../../store', () => ({ + useAppStore: Object.assign( + (selector: (state: typeof storeState) => T): T => selector(storeState), + { getState: () => storeState } + ) +})) + +vi.mock('./usePetUrl', () => ({ + usePetUrl: () => ({ + url: petUrlState.url, + ready: true, + sprite: { + frameWidth: 192, + frameHeight: 208, + columns: 8, + rows: 9, + sheetWidth: 1536, + sheetHeight: 1872, + fps: 8, + defaultAnimation: 'idle', + animations: { idle: { row: petUrlState.idleRow, frames: 6 } } + }, + detected: null + }) +})) + +import { PetOverlay } from './PetOverlay' + +function installLocalStorage(): void { + const values = new Map() + Object.defineProperty(window, 'localStorage', { + configurable: true, + value: { + clear: () => values.clear(), + getItem: (key: string) => values.get(key) ?? null, + removeItem: (key: string) => values.delete(key), + setItem: (key: string, value: string) => values.set(key, value) + } + }) +} + +function spriteDiv(container: HTMLElement): HTMLDivElement | undefined { + return Array.from(container.querySelectorAll('div')).find( + (div) => div.style.backgroundImage !== '' + ) +} + +describe('PetOverlay pet switching', () => { + let root: Root | null = null + let container: HTMLDivElement | null = null + + beforeEach(() => { + installLocalStorage() + petUrlState.url = 'blob:pet-a' + petUrlState.idleRow = 0 + }) + + afterEach(() => { + if (root) { + act(() => root?.unmount()) + } + container?.remove() + root = null + container = null + }) + + it('remounts the sprite on a pet change so it cannot inherit the prior timeline', () => { + container = document.createElement('div') + document.body.appendChild(container) + root = createRoot(container) + act(() => root?.render()) + const before = spriteDiv(container) + expect(before).toBeDefined() + + // Switch to a different cached pet whose idle maps to another row. + petUrlState.url = 'blob:pet-b' + petUrlState.idleRow = 2 + act(() => root?.render()) + + // key={url} makes React replace the element rather than update it in place; + // a fresh element starts its CSS animation at frame 0 instead of carrying + // the prior pet's currentTime. Without the key it would be the same node. + const after = spriteDiv(container) + expect(after).toBeDefined() + expect(after).not.toBe(before) + }) +}) diff --git a/src/renderer/src/components/pet/PetOverlay.pointer-interaction.test.tsx b/src/renderer/src/components/pet/PetOverlay.pointer-interaction.test.tsx new file mode 100644 index 00000000000..17fcdeb2cdc --- /dev/null +++ b/src/renderer/src/components/pet/PetOverlay.pointer-interaction.test.tsx @@ -0,0 +1,156 @@ +// @vitest-environment happy-dom + +import { act } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const storeState = vi.hoisted(() => ({ + agentStatusByPaneKey: {}, + agentStatusEpoch: 0, + retainedAgentsByPaneKey: {}, + petSize: 180 +})) + +vi.mock('../../store', () => ({ + useAppStore: Object.assign( + (selector: (state: typeof storeState) => T): T => selector(storeState), + { getState: () => storeState } + ) +})) + +vi.mock('./usePetUrl', () => ({ + usePetUrl: () => ({ + url: 'blob:custom-pet', + ready: true, + sprite: { + frameWidth: 192, + frameHeight: 208, + columns: 8, + rows: 9, + sheetWidth: 1536, + sheetHeight: 1872, + fps: 8, + defaultAnimation: 'idle', + animations: { + idle: { row: 0, frames: 6, frameDurationsMs: [1680, 660, 660, 840, 840, 1920] }, + 'running-right': { row: 1, frames: 8 } + } + }, + detected: null + }) +})) + +import { PetOverlay } from './PetOverlay' + +function renderPetOverlay(): { container: HTMLDivElement; root: Root } { + const container = document.createElement('div') + document.body.appendChild(container) + const root = createRoot(container) + act(() => { + root.render() + }) + return { container, root } +} + +function installLocalStorage(): void { + const values = new Map() + Object.defineProperty(window, 'localStorage', { + configurable: true, + value: { + clear: () => values.clear(), + getItem: (key: string) => values.get(key) ?? null, + removeItem: (key: string) => values.delete(key), + setItem: (key: string, value: string) => values.set(key, value) + } + }) +} + +function spriteDiv(container: HTMLElement): HTMLDivElement { + const div = Array.from(container.querySelectorAll('div')).find( + (candidate) => candidate.style.backgroundImage !== '' + ) + if (!div) { + throw new Error('sprite div not found') + } + return div +} + +// The @keyframes name is `pet---`: the +// animation name keeps a switched-to row from reusing the prior timeline, and +// the generation suffix restarts a same-row grab from frame 0. +function animationName(container: HTMLElement): string { + return spriteDiv(container).style.animation.split(' ')[0] +} + +function firePointer(target: Element, type: string, clientX: number, clientY: number): void { + act(() => { + target.dispatchEvent( + new PointerEvent(type, { clientX, clientY, button: 0, pointerId: 1, bubbles: true }) + ) + }) +} + +describe('PetOverlay grab-and-hold pointer interaction', () => { + let root: Root | null = null + let container: HTMLDivElement | null = null + + beforeEach(() => { + installLocalStorage() + }) + + afterEach(() => { + if (root) { + act(() => root?.unmount()) + } + container?.remove() + root = null + container = null + }) + + it('freezes on a stationary grab, then animates once dragged past the deadzone', () => { + ;({ container, root } = renderPetOverlay()) + const wrapper = container.querySelector('.pointer-events-auto') + if (!wrapper) { + throw new Error('draggable wrapper not found') + } + + // Baseline: the live idle row animates (idle carries per-frame durations, so + // it renders as step-end rather than steps()). + expect(spriteDiv(container).style.animationPlayState).toBe('running') + expect(spriteDiv(container).style.animation).toContain('step-end') + expect(animationName(container)).toContain('idle') + const idleName = animationName(container) + + // Grab and hold still: mint a fresh restart (frame 0) and freeze there while + // staying on the live idle row. + firePointer(wrapper, 'pointerdown', 50, 50) + expect(spriteDiv(container).style.animationPlayState).toBe('paused') + expect(spriteDiv(container).style.animation).toContain('step-end') + expect(animationName(container)).not.toBe(idleName) + const heldName = animationName(container) + + // A sub-4px twitch stays frozen (deadzone). + firePointer(wrapper, 'pointermove', 52, 51) + expect(spriteDiv(container).style.animationPlayState).toBe('paused') + + // A large vertical-only move keeps the hold (no horizontal direction yet). + firePointer(wrapper, 'pointermove', 52, 80) + expect(spriteDiv(container).style.animationPlayState).toBe('paused') + + // Drag right past the 4px deadzone: switch to the running-right row (row 1, + // 8 frames, no durations → steps(8)) and resume animating. The keyframes + // name changes with the row, so it starts from frame 0 rather than reusing + // the idle timeline. + firePointer(wrapper, 'pointermove', 70, 80) + expect(spriteDiv(container).style.animationPlayState).toBe('running') + expect(spriteDiv(container).style.animation).toContain('steps(8)') + expect(animationName(container)).toContain('running-right') + expect(animationName(container)).not.toBe(heldName) + + // Release restores the live agent state (idle) and resumes animating. + firePointer(wrapper, 'pointerup', 70, 80) + expect(spriteDiv(container).style.animationPlayState).toBe('running') + expect(spriteDiv(container).style.animation).toContain('step-end') + expect(animationName(container)).toContain('idle') + }) +}) diff --git a/src/renderer/src/components/pet/PetOverlay.tsx b/src/renderer/src/components/pet/PetOverlay.tsx index 89fc81dc6da..a4b13c1d69b 100644 --- a/src/renderer/src/components/pet/PetOverlay.tsx +++ b/src/renderer/src/components/pet/PetOverlay.tsx @@ -5,11 +5,21 @@ import type { DetectedSpriteCacheEntry } from './pet-blob-cache' import type { CustomPet } from '../../../../shared/types' import { useAppStore } from '../../store' import { AGENT_STATUS_STALE_AFTER_MS } from '../../../../shared/agent-status-types' -import { selectPetAnimationName, type PetAnimationName } from './pet-agent-state' +import { + selectPetAnimationName, + type PetAnimationName, + type PetDragAnimation +} from './pet-agent-state' +import { usePetPointerInteraction } from './usePetPointerInteraction' +import { buildSpriteAnimationCss } from './sprite-animation-css' type Sprite = NonNullable -function usePetAnimationName(dragging: boolean): PetAnimationName { +function usePetAnimationName( + dragging: boolean, + dragAnimation: PetDragAnimation, + hovering: boolean +): PetAnimationName { const agentStatusByPaneKey = useAppStore((s) => s.agentStatusByPaneKey) const agentStatusEpoch = useAppStore((s) => s.agentStatusEpoch) const retainedAgentsByPaneKey = useAppStore((s) => s.retainedAgentsByPaneKey) @@ -22,6 +32,8 @@ function usePetAnimationName(dragging: boolean): PetAnimationName { entries: Object.values(agentStatusByPaneKey), retainedCount: Object.keys(retainedAgentsByPaneKey).length, dragging, + dragAnimation, + hovering, now: Date.now(), staleAfterMs: AGENT_STATUS_STALE_AFTER_MS }) @@ -37,15 +49,21 @@ function SpriteFrame({ sprite, animate, maxSize, - animationName + animationName, + restartKey }: { url: string sprite: Sprite animate: boolean maxSize: number animationName: PetAnimationName + // Why: folded into the keyframes name, so bumping it mints a fresh animation + // that restarts from frame 0 even when the state row is unchanged. + restartKey: number }): React.JSX.Element { - const animKeyframesId = useId().replace(/[^a-zA-Z0-9_-]/g, '') + // Why: name the @keyframes per animation (+restartKey for same-row grabs) so a + // switched-to row starts at frame 0 instead of inheriting the prior timeline. + const animKeyframesId = `${useId().replace(/[^a-zA-Z0-9_-]/g, '')}-${animationName}-${restartKey}` const anim = sprite.animations?.[animationName] || (sprite.defaultAnimation && sprite.animations?.[sprite.defaultAnimation]) || @@ -63,11 +81,15 @@ function SpriteFrame({ const bgH = sprite.sheetHeight * scale const startX = 0 const startY = -(row * sprite.frameHeight * scale) - const endX = -(frames * sprite.frameWidth * scale) - const duration = Math.max(0.1, frames / Math.max(0.1, sprite.fps)) - // Why: sprite keyframes are runtime CSS, not user-visible copy; translated - // CSS keywords make the browser discard the animation. - const keyframesCss = `@keyframes pet-${animKeyframesId} { from { background-position: ${startX}px ${startY}px; } to { background-position: ${endX}px ${startY}px; } }` + const { keyframesCss, animationCss } = buildSpriteAnimationCss({ + keyframesId: animKeyframesId, + frames, + fps: sprite.fps, + frameWidth: sprite.frameWidth, + scale, + rowOffsetY: startY, + frameDurationsMs: anim?.frameDurationsMs + }) return ( <> @@ -80,7 +102,7 @@ function SpriteFrame({ backgroundSize: `${bgW}px ${bgH}px`, backgroundPosition: `${startX}px ${startY}px`, imageRendering: 'pixelated', - animation: `pet-${animKeyframesId} ${duration}s steps(${frames}) infinite`, + animation: animationCss, animationPlayState: animate ? 'running' : 'paused' }} /> @@ -324,8 +346,10 @@ export function PetOverlay(): React.JSX.Element { }, [size] ) - const [dragging, setDragging] = useState(false) - const dragOffsetRef = useRef({ x: 0, y: 0 }) + const { dragging, dragAnimation, hovering, dragGeneration, handlers } = usePetPointerInteraction( + position, + (next) => setPosition(clampToViewport(next, size)) + ) useEffect(() => { const onResize = (): void => setPosition((prev) => clampToViewport(prev, size)) @@ -344,46 +368,12 @@ export function PetOverlay(): React.JSX.Element { } }, [dragging, position]) - const animate = documentVisible && !reducedMotion && !dragging - const animationName = usePetAnimationName(dragging) - - // Why: setPointerCapture routes subsequent pointer events to this element - // even when the cursor leaves the OS window, so dragging can't get stuck in - // the "true" state if the user releases outside the app. - const onPointerDown = (event: React.PointerEvent): void => { - if (event.button !== 0) { - return - } - dragOffsetRef.current = { - x: event.clientX - position.x, - y: event.clientY - position.y - } - event.currentTarget.setPointerCapture(event.pointerId) - setDragging(true) - event.preventDefault() - } - - const onPointerMove = (event: React.PointerEvent): void => { - if (!dragging) { - return - } - setPosition( - clampToViewport( - { - x: event.clientX - dragOffsetRef.current.x, - y: event.clientY - dragOffsetRef.current.y - }, - size - ) - ) - } - - const endDrag = (event: React.PointerEvent): void => { - if (event.currentTarget.hasPointerCapture(event.pointerId)) { - event.currentTarget.releasePointerCapture(event.pointerId) - } - setDragging(false) - } + const motionAllowed = documentVisible && !reducedMotion + // Why: a still/vertical grab freezes on frame 0 (Codex grab-and-hold); a + // horizontal drag keeps animating so the running rows show. Bob always pauses. + const spriteAnimate = motionAllowed && (!dragging || dragAnimation !== null) + const bobAnimate = motionAllowed && !dragging + const animationName = usePetAnimationName(dragging, dragAnimation, hovering) return ( // Why: the outer box and middle layer stay pointer-events-none so app chrome @@ -401,15 +391,12 @@ export function PetOverlay(): React.JSX.Element { >
    {sprite ? ( + // Why: remount per pet so switching cached sprites can't inherit the + // previous pet's animation timeline (same @keyframes name → carried + // currentTime); each pet starts clean. ) : detected ? ( - + ) : ( // Why: cap explicitly at the pet size — the w-fit/h-fit wrapper is // fit-content, so max-w/h-full has no fixed box to resolve against diff --git a/src/renderer/src/components/pet/pet-agent-state.test.ts b/src/renderer/src/components/pet/pet-agent-state.test.ts index 4c6d28b7135..09dc0813b5b 100644 --- a/src/renderer/src/components/pet/pet-agent-state.test.ts +++ b/src/renderer/src/components/pet/pet-agent-state.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest' import type { AgentStatusEntry, AgentStatusState } from '../../../../shared/agent-status-types' -import { selectPetAnimationName } from './pet-agent-state' +import { nextPetDragAnimation, selectPetAnimationName } from './pet-agent-state' const NOW = 1_000 const STALE_AFTER_MS = 500 @@ -28,6 +28,8 @@ function select( entries, retainedCount: 0, dragging: false, + dragAnimation: null, + hovering: false, now: NOW, staleAfterMs: STALE_AFTER_MS, ...options @@ -63,7 +65,57 @@ describe('selectPetAnimationName', () => { expect(select([entry('working'), entry('done', { interrupted: true })])).toBe('running') }) - it('uses jumping while the pet is being dragged', () => { - expect(select([entry('blocked')], { dragging: true })).toBe('jumping') + it('keeps the live agent state while grabbed and held still (no drag direction)', () => { + expect(select([entry('blocked')], { dragging: true })).toBe('waiting') + expect(select([entry('working')], { dragging: true })).toBe('running') + expect(select([], { dragging: true })).toBe('idle') + }) + + it('runs toward the drag direction while the pet is dragged horizontally', () => { + expect(select([entry('blocked')], { dragging: true, dragAnimation: 'running-right' })).toBe( + 'running-right' + ) + expect(select([], { dragging: true, dragAnimation: 'running-left' })).toBe('running-left') + }) + + it('uses jumping while hovered but not dragging', () => { + expect(select([], { hovering: true })).toBe('jumping') + expect(select([entry('working')], { hovering: true })).toBe('jumping') + }) + + it('prefers the live state over hover while grabbed (drag suppresses the hover jump)', () => { + expect(select([entry('working')], { dragging: true, hovering: true })).toBe('running') + }) +}) + +describe('nextPetDragAnimation', () => { + it('keeps the direction and baseline for sub-threshold horizontal travel', () => { + expect(nextPetDragAnimation(null, 3)).toEqual({ animation: null, accepted: false }) + expect(nextPetDragAnimation('running-right', -3)).toEqual({ + animation: 'running-right', + accepted: false + }) + }) + + it('picks the horizontal direction at the 4px threshold and advances the baseline', () => { + expect(nextPetDragAnimation(null, 4)).toEqual({ + animation: 'running-right', + accepted: true + }) + expect(nextPetDragAnimation(null, -4)).toEqual({ + animation: 'running-left', + accepted: true + }) + }) + + it('keeps the last direction without advancing when horizontal travel stays under 4px', () => { + // A vertical/near-vertical drag has ~0 horizontal delta: keep the direction + // but do not reset the baseline, so accumulated horizontal travel still adds + // up toward the threshold on a slow diagonal drag. + expect(nextPetDragAnimation('running-left', 0)).toEqual({ + animation: 'running-left', + accepted: false + }) + expect(nextPetDragAnimation(null, 0)).toEqual({ animation: null, accepted: false }) }) }) diff --git a/src/renderer/src/components/pet/pet-agent-state.ts b/src/renderer/src/components/pet/pet-agent-state.ts index f45d54bad35..26be938dd8d 100644 --- a/src/renderer/src/components/pet/pet-agent-state.ts +++ b/src/renderer/src/components/pet/pet-agent-state.ts @@ -1,27 +1,50 @@ import type { AgentStatusEntry } from '../../../../shared/agent-status-types' import { isExplicitAgentStatusFresh } from '@/lib/agent-status' -export type PetAnimationName = 'idle' | 'running' | 'waiting' | 'review' | 'jumping' +export type PetAnimationName = + | 'idle' + | 'running' + | 'waiting' + | 'review' + | 'jumping' + | 'running-right' + | 'running-left' + +export type PetDragAnimation = 'running-right' | 'running-left' | null + +// Why: direction tracks horizontal travel only. `accepted` (advance the +// baseline) fires solely on a >=4px horizontal move, so sub-threshold jitter +// and vertical drags keep the last direction without resetting the baseline — +// otherwise a slow diagonal drag would reset before ever crossing 4px. +export function nextPetDragAnimation( + current: PetDragAnimation, + deltaX: number +): { animation: PetDragAnimation; accepted: boolean } { + if (deltaX >= 4) { + return { animation: 'running-right', accepted: true } + } + if (deltaX <= -4) { + return { animation: 'running-left', accepted: true } + } + return { animation: current, accepted: false } +} export type PetAnimationInput = { entries: AgentStatusEntry[] retainedCount: number dragging: boolean + dragAnimation: PetDragAnimation + hovering: boolean now: number staleAfterMs: number } -export function selectPetAnimationName({ - entries, - retainedCount, - dragging, - now, - staleAfterMs -}: PetAnimationInput): PetAnimationName { - if (dragging) { - return 'jumping' - } - +function agentStateAnimation( + entries: AgentStatusEntry[], + retainedCount: number, + now: number, + staleAfterMs: number +): PetAnimationName { let hasWorking = false let hasDone = false @@ -47,3 +70,24 @@ export function selectPetAnimationName({ } return 'idle' } + +export function selectPetAnimationName({ + entries, + retainedCount, + dragging, + dragAnimation, + hovering, + now, + staleAfterMs +}: PetAnimationInput): PetAnimationName { + const base = agentStateAnimation(entries, retainedCount, now, staleAfterMs) + // Why: aligned with Codex. A horizontal drag runs toward the pointer, + // grab-and-hold keeps the live agent state, and only a plain hover jumps. + if (dragging) { + return dragAnimation ?? base + } + if (hovering) { + return 'jumping' + } + return base +} diff --git a/src/renderer/src/components/pet/sprite-animation-css.test.ts b/src/renderer/src/components/pet/sprite-animation-css.test.ts new file mode 100644 index 00000000000..43f72c4afd2 --- /dev/null +++ b/src/renderer/src/components/pet/sprite-animation-css.test.ts @@ -0,0 +1,83 @@ +import { describe, expect, it } from 'vitest' +import { buildSpriteAnimationCss } from './sprite-animation-css' + +const BASE = { + keyframesId: 'kf', + frameWidth: 100, + scale: 1, + rowOffsetY: -200 +} + +describe('buildSpriteAnimationCss', () => { + it('emits one step-end stop per frame for uneven Codex-style pacing', () => { + const { keyframesCss, animationCss } = buildSpriteAnimationCss({ + ...BASE, + frames: 6, + fps: 8, + frameDurationsMs: [1680, 660, 660, 840, 840, 1920] + }) + + // Cumulative stops for [1680, 660, 660, 840, 840, 1920] over 6600ms. + expect(keyframesCss).toContain('0% { background-position: 0px -200px; }') + expect(keyframesCss).toContain('25.4545% { background-position: -100px -200px; }') + expect(keyframesCss).toContain('70.9091% { background-position: -500px -200px; }') + expect(keyframesCss).not.toContain(' to {') + expect(animationCss).toBe('pet-kf 6.6s step-end infinite') + }) + + it('falls back to uniform steps() when durations are absent, invalid, or corrupt', () => { + for (const frameDurationsMs of [ + undefined, + [100, 100], + [100, -1, 100], + [100, Number.NaN, 100], + // Untrusted persisted data: non-arrays with a matching `length` must not + // reach .every and throw. + { length: 3 } as unknown as number[], + 'aaa' as unknown as number[] + ]) { + const { keyframesCss, animationCss } = buildSpriteAnimationCss({ + ...BASE, + frames: 3, + fps: 6, + frameDurationsMs + }) + expect(keyframesCss).toBe( + '@keyframes pet-kf { from { background-position: 0px -200px; } to { background-position: -300px -200px; } }' + ) + expect(animationCss).toBe('pet-kf 0.5s steps(3) infinite') + } + }) + + it('degrades to uniform pacing rather than dropping a frame that rounds away', () => { + // A sub-precision frame among a ~minute total loses its own stop: a leading + // one collapses two stops to 0%, a trailing one rounds the final stop to + // 100% (no interval before the loop). Either way, degrade to steps() so + // every frame still renders instead of letting one vanish. + for (const frameDurationsMs of [ + [0.001, 60_000], + [60_000, 0.001] + ]) { + const { keyframesCss, animationCss } = buildSpriteAnimationCss({ + ...BASE, + frames: 2, + fps: 8, + frameDurationsMs + }) + expect(keyframesCss).not.toContain('step') + expect(keyframesCss).toContain(' to {') + expect(animationCss).toContain('steps(2)') + } + }) + + it('renders a genuinely short-but-representable frame as its own stop', () => { + const { keyframesCss } = buildSpriteAnimationCss({ + ...BASE, + frames: 2, + fps: 8, + frameDurationsMs: [10, 990] + }) + expect(keyframesCss).toContain('0% { background-position: 0px -200px; }') + expect(keyframesCss).toContain('1% { background-position: -100px -200px; }') + }) +}) diff --git a/src/renderer/src/components/pet/sprite-animation-css.ts b/src/renderer/src/components/pet/sprite-animation-css.ts new file mode 100644 index 00000000000..f04a8ecadf3 --- /dev/null +++ b/src/renderer/src/components/pet/sprite-animation-css.ts @@ -0,0 +1,99 @@ +// Sprite-sheet keyframe CSS for the pet overlay. Kept as a pure module so the +// pacing math is unit-testable without mounting the overlay or a DOM. + +export type SpriteAnimationCss = { + keyframesCss: string + animationCss: string +} + +export type SpriteAnimationCssInput = { + // Sanitized `@keyframes` identifier; folded with the restart key by the caller. + keyframesId: string + frames: number + fps: number + frameWidth: number + scale: number + // Vertical offset selecting the animation's row on the sheet. + rowOffsetY: number + // Per-frame holds in ms; uneven pacing renders when they pass validation. + frameDurationsMs: number[] | undefined +} + +// Why: sprite keyframes are runtime CSS, not user-visible copy; translated CSS +// keywords make the browser discard the animation, so keep them out of i18n. +export function buildSpriteAnimationCss({ + keyframesId, + frames, + fps, + frameWidth, + scale, + rowOffsetY, + frameDurationsMs +}: SpriteAnimationCssInput): SpriteAnimationCss { + const name = `pet-${keyframesId}` + const durations = validFrameDurations(frameDurationsMs, frames) + if (durations) { + const totalMs = durations.reduce((sum, ms) => sum + ms, 0) + // Why: Codex pets hold frames unevenly (idle rests ~1.9s on its last frame). + // steps() can't express that, so emit one step-end stop per frame. + const stops = stepEndStops(durations, totalMs, frameWidth, scale, rowOffsetY) + if (stops) { + return { + keyframesCss: `@keyframes ${name} { ${stops.join(' ')} }`, + animationCss: `${name} ${totalMs / 1000}s step-end infinite` + } + } + } + // Uniform sheet fps: one steps() run across the row. + const duration = Math.max(0.1, frames / Math.max(0.1, fps)) + const endX = -(frames * frameWidth * scale) + return { + keyframesCss: `@keyframes ${name} { from { background-position: 0px ${rowOffsetY}px; } to { background-position: ${endX}px ${rowOffsetY}px; } }`, + animationCss: `${name} ${duration}s steps(${frames}) infinite` + } +} + +function validFrameDurations( + frameDurationsMs: number[] | undefined, + frames: number +): number[] | null { + // Why: Array.isArray, not a truthiness check — persisted/RPC-synced sprites + // are untrusted, so a corrupt non-array value (e.g. { length: 6 }) must fail + // here rather than throw on .length/.every during render. + if ( + Array.isArray(frameDurationsMs) && + frameDurationsMs.length === frames && + frameDurationsMs.every((ms) => Number.isFinite(ms) && ms > 0) + ) { + return frameDurationsMs + } + return null +} + +// Cumulative step-end stops, one per frame. Returns null (→ uniform fallback) +// when a frame is too short to survive our 4-decimal precision: either two +// stops collapse to the same percentage, or the final stop rounds to 100% and +// so has no interval before the loop. Either way the frame would vanish, so we +// degrade to uniform pacing rather than silently drop it. +function stepEndStops( + durations: number[], + totalMs: number, + frameWidth: number, + scale: number, + rowOffsetY: number +): string[] | null { + const stops: string[] = [] + let elapsedMs = 0 + let previousPct = -1 + for (let index = 0; index < durations.length; index++) { + const pct = +((elapsedMs / totalMs) * 100).toFixed(4) + if (pct <= previousPct || pct >= 100) { + return null + } + previousPct = pct + const x = -(index * frameWidth * scale) + stops.push(`${pct}% { background-position: ${x}px ${rowOffsetY}px; }`) + elapsedMs += durations[index] + } + return stops +} diff --git a/src/renderer/src/components/pet/usePetPointerInteraction.test.ts b/src/renderer/src/components/pet/usePetPointerInteraction.test.ts new file mode 100644 index 00000000000..e234c32986d --- /dev/null +++ b/src/renderer/src/components/pet/usePetPointerInteraction.test.ts @@ -0,0 +1,142 @@ +// @vitest-environment happy-dom + +import { act, renderHook } from '@testing-library/react' +import { describe, expect, it, vi } from 'vitest' +import { usePetPointerInteraction } from './usePetPointerInteraction' + +type HandlerName = keyof ReturnType['handlers'] + +function fakeTarget(): { + setPointerCapture: (id: number) => void + hasPointerCapture: (id: number) => boolean + releasePointerCapture: (id: number) => void +} { + const captured = new Set() + return { + setPointerCapture: (id) => void captured.add(id), + hasPointerCapture: (id) => captured.has(id), + releasePointerCapture: (id) => void captured.delete(id) + } +} + +function setup() { + const moveTo = vi.fn() + const target = fakeTarget() + const { result } = renderHook(() => usePetPointerInteraction({ x: 0, y: 0 }, moveTo)) + + const event = (props: { + clientX?: number + clientY?: number + pointerId?: number + button?: number + }) => + ({ + button: 0, + pointerId: 1, + clientX: 0, + clientY: 0, + currentTarget: target, + preventDefault: () => {}, + ...props + }) as unknown as Parameters< + ReturnType['handlers']['onPointerDown'] + >[0] + + const fire = (name: HandlerName, props: Parameters[0] = {}): void => { + act(() => result.current.handlers[name](event(props))) + } + + return { result, moveTo, fire, event } +} + +describe('usePetPointerInteraction', () => { + it('starts a drag and bumps the restart generation on a primary grab', () => { + const { result, fire } = setup() + fire('onPointerDown', { clientX: 50, clientY: 50 }) + expect(result.current.dragging).toBe(true) + expect(result.current.dragAnimation).toBe(null) + expect(result.current.dragGeneration).toBe(1) + }) + + it('runs toward the horizontal drag direction past the 4px deadzone', () => { + const { result, fire, moveTo } = setup() + fire('onPointerDown', { clientX: 50, clientY: 50 }) + fire('onPointerMove', { clientX: 58, clientY: 50 }) + expect(result.current.dragAnimation).toBe('running-right') + expect(moveTo).toHaveBeenLastCalledWith({ x: 8, y: 0 }) + fire('onPointerMove', { clientX: 40, clientY: 50 }) + expect(result.current.dragAnimation).toBe('running-left') + }) + + it('ignores sub-deadzone jitter and keeps the last direction on vertical moves', () => { + const { result, fire } = setup() + fire('onPointerDown', { clientX: 50, clientY: 50 }) + fire('onPointerMove', { clientX: 52, clientY: 52 }) + expect(result.current.dragAnimation).toBe(null) + fire('onPointerMove', { clientX: 58, clientY: 52 }) + expect(result.current.dragAnimation).toBe('running-right') + fire('onPointerMove', { clientX: 58, clientY: 100 }) + expect(result.current.dragAnimation).toBe('running-right') + }) + + it('accumulates horizontal travel across a slow diagonal drag', () => { + // Regression: the baseline advances only on an accepted direction, so a + // sequence of sub-4px diagonal moves still crosses the threshold instead of + // resetting each time and never triggering. + const { result, fire } = setup() + fire('onPointerDown', { clientX: 50, clientY: 50 }) + fire('onPointerMove', { clientX: 53, clientY: 55 }) + expect(result.current.dragAnimation).toBe(null) + fire('onPointerMove', { clientX: 56, clientY: 60 }) + expect(result.current.dragAnimation).toBe('running-right') + }) + + it('keeps the latest horizontal direction when two moves land in one commit', () => { + // Regression: the direction lives in a ref, not the state closure, so a + // vertical move batched right after a horizontal one cannot resurrect the + // stale prior direction. + const { result } = renderHook(() => usePetPointerInteraction({ x: 0, y: 0 }, vi.fn())) + const target = fakeTarget() + const ev = (clientX: number, clientY: number) => + ({ + button: 0, + pointerId: 1, + clientX, + clientY, + currentTarget: target, + preventDefault: () => {} + }) as never + + act(() => result.current.handlers.onPointerDown(ev(50, 50))) + act(() => { + result.current.handlers.onPointerMove(ev(40, 50)) // left + result.current.handlers.onPointerMove(ev(40, 90)) // vertical from the new sample + }) + expect(result.current.dragAnimation).toBe('running-left') + }) + + it('scopes the drag to the owning pointer and ignores a second touch', () => { + const { result, fire, moveTo } = setup() + fire('onPointerDown', { clientX: 50, clientY: 50, pointerId: 1 }) + // A second pointer must not hijack the drag or mint a new restart. + fire('onPointerDown', { clientX: 50, clientY: 50, pointerId: 2 }) + expect(result.current.dragGeneration).toBe(1) + fire('onPointerMove', { clientX: 90, clientY: 50, pointerId: 2 }) + expect(moveTo).not.toHaveBeenCalled() + // Releasing the non-owning pointer leaves the drag alive. + fire('onPointerUp', { pointerId: 2 }) + expect(result.current.dragging).toBe(true) + // The owning pointer ends it. + fire('onPointerUp', { pointerId: 1 }) + expect(result.current.dragging).toBe(false) + expect(result.current.dragAnimation).toBe(null) + }) + + it('ends the drag when pointer capture is lost', () => { + const { result, fire } = setup() + fire('onPointerDown', { clientX: 50, clientY: 50 }) + fire('onLostPointerCapture', { pointerId: 1 }) + expect(result.current.dragging).toBe(false) + expect(result.current.dragAnimation).toBe(null) + }) +}) diff --git a/src/renderer/src/components/pet/usePetPointerInteraction.ts b/src/renderer/src/components/pet/usePetPointerInteraction.ts new file mode 100644 index 00000000000..9a3afd29d39 --- /dev/null +++ b/src/renderer/src/components/pet/usePetPointerInteraction.ts @@ -0,0 +1,119 @@ +import { useRef, useState, type PointerEvent as ReactPointerEvent } from 'react' +import { nextPetDragAnimation, type PetDragAnimation } from './pet-agent-state' + +type Point = { x: number; y: number } + +export type PetPointerInteraction = { + dragging: boolean + dragAnimation: PetDragAnimation + hovering: boolean + // Why: bumped on grab so the sprite restarts from frame 0, aligned with the + // Codex mascot. + dragGeneration: number + handlers: { + onPointerDown: (event: ReactPointerEvent) => void + onPointerMove: (event: ReactPointerEvent) => void + onPointerUp: (event: ReactPointerEvent) => void + onPointerCancel: (event: ReactPointerEvent) => void + onLostPointerCapture: (event: ReactPointerEvent) => void + onPointerEnter: () => void + onPointerLeave: () => void + } +} + +// Drag/hover state for the pet overlay. `position` is the overlay's current +// top-left corner and `moveTo` receives the unclamped position the drag wants. +export function usePetPointerInteraction( + position: Point, + moveTo: (next: Point) => void +): PetPointerInteraction { + const [dragging, setDragging] = useState(false) + const [dragAnimation, setDragAnimation] = useState(null) + const [hovering, setHovering] = useState(false) + const [dragGeneration, setDragGeneration] = useState(0) + const dragOffsetRef = useRef({ x: 0, y: 0 }) + // Why: horizontal baseline for the drag-direction hysteresis, advanced only on + // an accepted direction. Kept separate from dragOffsetRef (position math). + const dragBaselineXRef = useRef(0) + // Why: direction and the owning pointer are read+written inside pointer + // handlers, so keep them in refs immune to React's render batching. Reading + // the direction from state would let two coalesced moves in one commit + // resurrect a stale direction; `dragAnimation` state exists only to render. + const dragDirectionRef = useRef(null) + const activePointerRef = useRef(null) + + // Why: setPointerCapture routes subsequent pointer events to this element + // even when the cursor leaves the OS window, so dragging can't get stuck in + // the "true" state if the user releases outside the app. + const onPointerDown = (event: ReactPointerEvent): void => { + // Why: primary button only, and one pointer owns the drag — a second touch + // must not hijack the anchors mid-drag. + if (event.button !== 0 || activePointerRef.current !== null) { + return + } + activePointerRef.current = event.pointerId + dragOffsetRef.current = { + x: event.clientX - position.x, + y: event.clientY - position.y + } + dragBaselineXRef.current = event.clientX + dragDirectionRef.current = null + event.currentTarget.setPointerCapture(event.pointerId) + setDragging(true) + setDragAnimation(null) + setDragGeneration((generation) => generation + 1) + event.preventDefault() + } + + const onPointerMove = (event: ReactPointerEvent): void => { + if (event.pointerId !== activePointerRef.current) { + return + } + const next = nextPetDragAnimation( + dragDirectionRef.current, + event.clientX - dragBaselineXRef.current + ) + if (next.accepted) { + dragBaselineXRef.current = event.clientX + if (next.animation !== dragDirectionRef.current) { + dragDirectionRef.current = next.animation + setDragAnimation(next.animation) + } + } + moveTo({ + x: event.clientX - dragOffsetRef.current.x, + y: event.clientY - dragOffsetRef.current.y + }) + } + + const endDrag = (event: ReactPointerEvent): void => { + if (event.pointerId !== activePointerRef.current) { + return + } + activePointerRef.current = null + dragDirectionRef.current = null + if (event.currentTarget.hasPointerCapture(event.pointerId)) { + event.currentTarget.releasePointerCapture(event.pointerId) + } + setDragging(false) + setDragAnimation(null) + } + + return { + dragging, + dragAnimation, + hovering, + dragGeneration, + handlers: { + onPointerDown, + onPointerMove, + onPointerUp: endDrag, + onPointerCancel: endDrag, + // Why: capture can be revoked without a pointerup (e.g. the element loses + // it); treat that as the end of the drag so it can't wedge on. + onLostPointerCapture: endDrag, + onPointerEnter: () => setHovering(true), + onPointerLeave: () => setHovering(false) + } + } +} diff --git a/src/renderer/src/components/pet/usePetUrl.ts b/src/renderer/src/components/pet/usePetUrl.ts index 60c5dbef9d7..efc9fc05568 100644 --- a/src/renderer/src/components/pet/usePetUrl.ts +++ b/src/renderer/src/components/pet/usePetUrl.ts @@ -1,5 +1,6 @@ import { useEffect, useLayoutEffect, useRef, useState } from 'react' import type { CustomPet } from '../../../../shared/types' +import { applyCodexSpriteTimingDefaults } from '../../../../shared/codex-pet-sprite-defaults' import { useAppStore } from '../../store' import { BUNDLED_PET, findBundledPet, isBundledPetId } from './pet-models' import { @@ -114,7 +115,14 @@ export function usePetUrl(): ResolvedPet { customMeta.sprite.frameHeight > 0 && customMeta.sprite.fps > 0 ) { - return { url: customUrl, ready: true, sprite: customMeta.sprite, detected: null } + // Why: sprites persisted before per-frame durations existed pace ~9x too + // fast. Upgrade the legacy Codex fingerprint without forcing a re-import. + return { + url: customUrl, + ready: true, + sprite: applyCodexSpriteTimingDefaults(customMeta.sprite), + detected: null + } } const detected = detectedSpriteCache.get(customMeta.id) if (detected) { diff --git a/src/shared/codex-pet-sprite-defaults.test.ts b/src/shared/codex-pet-sprite-defaults.test.ts new file mode 100644 index 00000000000..efe6521acd4 --- /dev/null +++ b/src/shared/codex-pet-sprite-defaults.test.ts @@ -0,0 +1,111 @@ +import { describe, expect, it } from 'vitest' +import { + CODEX_PET_ANIMATIONS, + CODEX_PET_DEFAULT_COLUMNS, + CODEX_PET_DEFAULT_FPS, + applyCodexSpriteTimingDefaults, + type CustomPetSprite +} from './codex-pet-sprite-defaults' + +function legacyCodexSprite(): CustomPetSprite { + return { + frameWidth: 192, + frameHeight: 208, + columns: 8, + rows: 9, + sheetWidth: 1536, + sheetHeight: 1872, + fps: CODEX_PET_DEFAULT_FPS, + defaultAnimation: 'idle', + // Pre-durations builds baked exactly this shape from the old defaults. + animations: Object.fromEntries( + Object.entries(CODEX_PET_ANIMATIONS).map(([name, anim]) => [ + name, + { row: anim.row, frames: anim.frames } + ]) + ) + } +} + +describe('CODEX_PET_ANIMATIONS', () => { + it('ships per-frame durations matching each frame count', () => { + for (const [name, anim] of Object.entries(CODEX_PET_ANIMATIONS)) { + expect(anim.frameDurationsMs, name).toBeDefined() + expect(anim.frameDurationsMs, name).toHaveLength(anim.frames) + for (const ms of anim.frameDurationsMs ?? []) { + expect(ms, name).toBeGreaterThan(0) + } + } + }) + + it('keeps the Codex ambient idle cycle at 6.6 seconds', () => { + const total = (CODEX_PET_ANIMATIONS.idle.frameDurationsMs ?? []).reduce( + (sum, ms) => sum + ms, + 0 + ) + expect(total).toBe(6600) + }) +}) + +describe('applyCodexSpriteTimingDefaults', () => { + it('upgrades the legacy baked-default fingerprint with durations', () => { + const upgraded = applyCodexSpriteTimingDefaults(legacyCodexSprite()) + expect(upgraded.animations).toEqual(CODEX_PET_ANIMATIONS) + expect(upgraded.animations?.idle.frameDurationsMs).toEqual([1680, 660, 660, 840, 840, 1920]) + }) + + it('leaves hand-authored animation layouts untouched', () => { + const sprite = legacyCodexSprite() + sprite.animations = { blink: { row: 0, frames: 2 } } + expect(applyCodexSpriteTimingDefaults(sprite)).toBe(sprite) + }) + + it('leaves sprites with a non-default fps untouched', () => { + const sprite = { ...legacyCodexSprite(), fps: 4 } + expect(applyCodexSpriteTimingDefaults(sprite)).toBe(sprite) + }) + + it('leaves sprites that already declare durations untouched', () => { + const sprite = legacyCodexSprite() + sprite.animations = { + ...sprite.animations, + idle: { row: 0, frames: 6, frameDurationsMs: [100, 100, 100, 100, 100, 100] } + } + expect(applyCodexSpriteTimingDefaults(sprite)).toBe(sprite) + }) + + it('leaves sprites without animations untouched', () => { + const sprite = { ...legacyCodexSprite(), animations: undefined } + expect(applyCodexSpriteTimingDefaults(sprite)).toBe(sprite) + }) + + it('leaves a hand-authored 8 fps sheet that only reuses the Codex row map untouched', () => { + // Same fps + nine Codex rows, but non-Codex frame geometry — must not retime. + const sprite: CustomPetSprite = { + ...legacyCodexSprite(), + frameWidth: 32, + frameHeight: 32, + sheetWidth: CODEX_PET_DEFAULT_COLUMNS * 32, + sheetHeight: 9 * 32 + } + expect(applyCodexSpriteTimingDefaults(sprite)).toBe(sprite) + }) + + it('leaves a sheet with a non-idle default animation untouched', () => { + const sprite = { ...legacyCodexSprite(), defaultAnimation: 'running' } + expect(applyCodexSpriteTimingDefaults(sprite)).toBe(sprite) + }) + + it('leaves a sheet with a non-Codex column count untouched', () => { + const sprite = { ...legacyCodexSprite(), columns: CODEX_PET_DEFAULT_COLUMNS + 2 } + expect(applyCodexSpriteTimingDefaults(sprite)).toBe(sprite) + }) + + it('does not throw on a corrupted animation entry that matches the outer fingerprint', () => { + const sprite = legacyCodexSprite() + // Persisted data is untrusted: a null value under a matching key must be a + // non-match, not a crash. + sprite.animations = { ...sprite.animations, idle: null as never } + expect(applyCodexSpriteTimingDefaults(sprite)).toBe(sprite) + }) +}) diff --git a/src/shared/codex-pet-sprite-defaults.ts b/src/shared/codex-pet-sprite-defaults.ts new file mode 100644 index 00000000000..b33625d6202 --- /dev/null +++ b/src/shared/codex-pet-sprite-defaults.ts @@ -0,0 +1,100 @@ +import type { CustomPet, SpriteAnimation } from './types' + +/** Codex pet sprite layout and pacing, mirroring the tables in Codex CLI's + * `tui/src/pets`. Shared because main bakes these into imported bundles and + * the renderer needs the same fingerprint to upgrade legacy persisted pets. */ + +export const CODEX_PET_SPRITESHEET_PATH = 'spritesheet.webp' +export const CODEX_PET_FRAME = { width: 192, height: 208 } as const +export const CODEX_PET_DEFAULT_ANIMATION = 'idle' +export const CODEX_PET_DEFAULT_FPS = 8 +// Codex sheets are 8 columns wide (its widest rows run 8 frames). +export const CODEX_PET_DEFAULT_COLUMNS = 8 + +// Codex's `app_state_animation`: every non-final frame holds `frameMs`, the last +// holds `finalMs`. Values below mirror `codex-rs/tui/src/pets/model.rs` exactly. +function appStateDurations(frames: number, frameMs: number, finalMs: number): number[] { + return Array.from({ length: frames }, (_, i) => (i === frames - 1 ? finalMs : frameMs)) +} + +export const CODEX_PET_ANIMATIONS: Record = { + // Idle is the ambient loop: long holds on the bookend frames, 6.6s cycle. + idle: { row: 0, frames: 6, frameDurationsMs: [1680, 660, 660, 840, 840, 1920] }, + 'running-right': { row: 1, frames: 8, frameDurationsMs: appStateDurations(8, 120, 220) }, + 'running-left': { row: 2, frames: 8, frameDurationsMs: appStateDurations(8, 120, 220) }, + waving: { row: 3, frames: 4, frameDurationsMs: appStateDurations(4, 140, 280) }, + jumping: { row: 4, frames: 5, frameDurationsMs: appStateDurations(5, 140, 280) }, + failed: { row: 5, frames: 8, frameDurationsMs: appStateDurations(8, 140, 240) }, + waiting: { row: 6, frames: 6, frameDurationsMs: appStateDurations(6, 150, 260) }, + running: { row: 7, frames: 6, frameDurationsMs: appStateDurations(6, 120, 220) }, + review: { row: 8, frames: 6, frameDurationsMs: appStateDurations(6, 150, 280) } +} + +/** The Codex row layout paced uniformly at `fps` (each frame held 1000/fps ms). + * A bundle that pins an explicit fps gets its pacing baked as durations, so the + * fps is honored (SpriteFrame prefers durations over the sheet fps) and the + * sprite carries durations — which keeps it distinct from the no-durations + * legacy fingerprint below, so the render-time upgrade never retimes it. */ +export function codexAnimationsAtUniformFps(fps: number): Record { + const frameMs = 1000 / fps + return Object.fromEntries( + Object.entries(CODEX_PET_ANIMATIONS).map(([name, { row, frames }]) => [ + name, + { row, frames, frameDurationsMs: Array.from({ length: frames }, () => frameMs) } + ]) + ) +} + +export type CustomPetSprite = NonNullable + +/** The exact sprite an old Orca build baked for an imported Codex bundle: + * 192x208 frames on an 8-wide sheet at the flat 8 fps rate, idle default, and + * the nine Codex rows carrying no per-frame durations. Matching the full + * geometry (not just the row map) keeps a hand-authored 8 fps sheet that merely + * reuses those rows from being silently retimed. A pet deliberately built on + * the Codex layout that wants to keep uniform pacing opts out by declaring any + * frameDurationsMs of its own. + * + * Rows/sheet height are intentionally excluded so v2 (11-row) Codex sheets, + * which still bake these nine animations, upgrade too. */ +function isLegacyCodexSprite(sprite: CustomPetSprite): boolean { + const animations = sprite.animations + if ( + !animations || + sprite.fps !== CODEX_PET_DEFAULT_FPS || + sprite.frameWidth !== CODEX_PET_FRAME.width || + sprite.frameHeight !== CODEX_PET_FRAME.height || + sprite.columns !== CODEX_PET_DEFAULT_COLUMNS || + sprite.defaultAnimation !== CODEX_PET_DEFAULT_ANIMATION + ) { + return false + } + const names = Object.keys(animations) + if (names.length !== Object.keys(CODEX_PET_ANIMATIONS).length) { + return false + } + return names.every((name) => { + const anim = animations[name] + const preset = CODEX_PET_ANIMATIONS[name] + // Why: `anim` is untrusted persisted data — guard it so a corrupted entry + // (e.g. a null value) is a non-match rather than a render-time throw. + return ( + !!preset && + !!anim && + anim.row === preset.row && + anim.frames === preset.frames && + anim.frameDurationsMs === undefined + ) + }) +} + +/** Pets imported before per-frame durations existed persist the legacy Codex + * fingerprint at the flat 8 fps sheet rate, so swap in the current defaults for + * those. Anything else passes through untouched. Render-time only — persisted + * data is never rewritten, so the upgrade is reversible on downgrade. */ +export function applyCodexSpriteTimingDefaults(sprite: CustomPetSprite): CustomPetSprite { + if (!isLegacyCodexSprite(sprite)) { + return sprite + } + return { ...sprite, animations: { ...CODEX_PET_ANIMATIONS } } +} diff --git a/src/shared/types.ts b/src/shared/types.ts index 36297481741..b54496da29e 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -3602,6 +3602,8 @@ export type CustomPet = { export type SpriteAnimation = { row: number frames: number + /** Per-frame holds in ms (length === frames). Absent means uniform sheet fps. */ + frameDurationsMs?: number[] } export type PersistedTrustedOrcaHookEntry = { From 0cd50eecdb28046dd3b7e6a4896bd9468c46f832 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Thu, 16 Jul 2026 22:21:58 -0700 Subject: [PATCH 24/24] fix(pet): key sprite keyframes on the resolved track; cap render durations (#9140) Follow-up polish to #8730: key @keyframes on the resolved row/frames (so a same-row fallback like hover on a jumping-less pet doesn't restart idle), mirror the importer's 60s per-frame cap at render, and trim comments to the 1-2 line convention. --- .../PetOverlay.pointer-interaction.test.tsx | 32 ++++++++++++++----- .../src/components/pet/PetOverlay.tsx | 14 ++++---- .../src/components/pet/pet-agent-state.ts | 6 ++-- .../pet/sprite-animation-css.test.ts | 3 ++ .../components/pet/sprite-animation-css.ts | 18 ++++++----- .../pet/usePetPointerInteraction.ts | 6 ++-- 6 files changed, 49 insertions(+), 30 deletions(-) diff --git a/src/renderer/src/components/pet/PetOverlay.pointer-interaction.test.tsx b/src/renderer/src/components/pet/PetOverlay.pointer-interaction.test.tsx index 17fcdeb2cdc..5533b5f9052 100644 --- a/src/renderer/src/components/pet/PetOverlay.pointer-interaction.test.tsx +++ b/src/renderer/src/components/pet/PetOverlay.pointer-interaction.test.tsx @@ -75,8 +75,8 @@ function spriteDiv(container: HTMLElement): HTMLDivElement { return div } -// The @keyframes name is `pet---`: the -// animation name keeps a switched-to row from reusing the prior timeline, and +// The @keyframes name is `pet----`: the +// resolved track keeps a switched-to row from reusing the prior timeline, and // the generation suffix restarts a same-row grab from frame 0. function animationName(container: HTMLElement): string { return spriteDiv(container).style.animation.split(' ')[0] @@ -118,7 +118,6 @@ describe('PetOverlay grab-and-hold pointer interaction', () => { // it renders as step-end rather than steps()). expect(spriteDiv(container).style.animationPlayState).toBe('running') expect(spriteDiv(container).style.animation).toContain('step-end') - expect(animationName(container)).toContain('idle') const idleName = animationName(container) // Grab and hold still: mint a fresh restart (frame 0) and freeze there while @@ -139,18 +138,35 @@ describe('PetOverlay grab-and-hold pointer interaction', () => { // Drag right past the 4px deadzone: switch to the running-right row (row 1, // 8 frames, no durations → steps(8)) and resume animating. The keyframes - // name changes with the row, so it starts from frame 0 rather than reusing - // the idle timeline. + // name changes with the resolved row, so it starts from frame 0 rather than + // reusing the idle timeline. firePointer(wrapper, 'pointermove', 70, 80) expect(spriteDiv(container).style.animationPlayState).toBe('running') expect(spriteDiv(container).style.animation).toContain('steps(8)') - expect(animationName(container)).toContain('running-right') expect(animationName(container)).not.toBe(heldName) - // Release restores the live agent state (idle) and resumes animating. + // Release returns to the idle row (step-end) and resumes animating. Same row + // and generation as the grab-hold, so it's the held-idle identity again. firePointer(wrapper, 'pointerup', 70, 80) expect(spriteDiv(container).style.animationPlayState).toBe('running') expect(spriteDiv(container).style.animation).toContain('step-end') - expect(animationName(container)).toContain('idle') + expect(animationName(container)).toBe(heldName) + }) + + it('does not restart the idle track when hover falls back to the same row', () => { + ;({ container, root } = renderPetOverlay()) + const wrapper = container.querySelector('.pointer-events-auto') + if (!wrapper) { + throw new Error('draggable wrapper not found') + } + // The mocked sprite has no jumping row, so hover (→ jumping) resolves back to + // idle. Keying the keyframes on the resolved row means the name must not + // change — otherwise the unchanged idle animation restarts on hover. React + // synthesizes onPointerEnter/Leave from pointerover/pointerout. + const idleName = animationName(container) + firePointer(wrapper, 'pointerover', 0, 0) + expect(animationName(container)).toBe(idleName) + firePointer(wrapper, 'pointerout', 0, 0) + expect(animationName(container)).toBe(idleName) }) }) diff --git a/src/renderer/src/components/pet/PetOverlay.tsx b/src/renderer/src/components/pet/PetOverlay.tsx index a4b13c1d69b..052ff64750a 100644 --- a/src/renderer/src/components/pet/PetOverlay.tsx +++ b/src/renderer/src/components/pet/PetOverlay.tsx @@ -61,9 +61,7 @@ function SpriteFrame({ // that restarts from frame 0 even when the state row is unchanged. restartKey: number }): React.JSX.Element { - // Why: name the @keyframes per animation (+restartKey for same-row grabs) so a - // switched-to row starts at frame 0 instead of inheriting the prior timeline. - const animKeyframesId = `${useId().replace(/[^a-zA-Z0-9_-]/g, '')}-${animationName}-${restartKey}` + const baseId = useId().replace(/[^a-zA-Z0-9_-]/g, '') const anim = sprite.animations?.[animationName] || (sprite.defaultAnimation && sprite.animations?.[sprite.defaultAnimation]) || @@ -72,6 +70,11 @@ function SpriteFrame({ // Why: clamp to >=1 so an empty/invalid manifest can't produce steps(0), // which is rejected as invalid CSS and freezes the animation. const frames = Math.max(1, anim?.frames ?? sprite.columns ?? 1) + // Why: name the @keyframes by the RESOLVED track (+restartKey for same-row + // grabs), so a genuine row change starts at frame 0 while a state that falls + // back to the same row (e.g. hover on a pet without a jumping row) doesn't + // needlessly restart. + const animKeyframesId = `${baseId}-${row}-${frames}-${restartKey}` // Why: allow fractional downscaling so frames larger than maxSize shrink to // fit instead of overflowing the overlay; mirrors DetectedSpriteFrame's math. const scale = Math.min(maxSize / sprite.frameWidth, maxSize / sprite.frameHeight) @@ -406,9 +409,8 @@ export function PetOverlay(): React.JSX.Element { > {sprite ? ( - // Why: remount per pet so switching cached sprites can't inherit the - // previous pet's animation timeline (same @keyframes name → carried - // currentTime); each pet starts clean. + // Why: remount per pet so a switched-to sprite starts a fresh + // animation instead of inheriting the prior pet's currentTime. =4px horizontal move, so sub-threshold jitter -// and vertical drags keep the last direction without resetting the baseline — -// otherwise a slow diagonal drag would reset before ever crossing 4px. +// Why: direction tracks horizontal travel only; `accepted` (advance the baseline) +// fires only on a >=4px horizontal move so slow diagonal drags still accumulate. export function nextPetDragAnimation( current: PetDragAnimation, deltaX: number diff --git a/src/renderer/src/components/pet/sprite-animation-css.test.ts b/src/renderer/src/components/pet/sprite-animation-css.test.ts index 43f72c4afd2..cf694e66bd4 100644 --- a/src/renderer/src/components/pet/sprite-animation-css.test.ts +++ b/src/renderer/src/components/pet/sprite-animation-css.test.ts @@ -31,6 +31,9 @@ describe('buildSpriteAnimationCss', () => { [100, 100], [100, -1, 100], [100, Number.NaN, 100], + // Above the importer's 60s cap: corrupt/hand-edited persisted holds must + // not freeze the overlay. + [100, 70_000, 100], // Untrusted persisted data: non-arrays with a matching `length` must not // reach .every and throw. { length: 3 } as unknown as number[], diff --git a/src/renderer/src/components/pet/sprite-animation-css.ts b/src/renderer/src/components/pet/sprite-animation-css.ts index f04a8ecadf3..0970acaa7aa 100644 --- a/src/renderer/src/components/pet/sprite-animation-css.ts +++ b/src/renderer/src/components/pet/sprite-animation-css.ts @@ -1,6 +1,10 @@ // Sprite-sheet keyframe CSS for the pet overlay. Kept as a pure module so the // pacing math is unit-testable without mounting the overlay or a DOM. +// Mirror of the importer's per-frame cap (pet.ts zod schema): a hold longer than +// this at render would freeze the overlay, so reject it on the render side too. +const MAX_FRAME_DURATION_MS = 60_000 + export type SpriteAnimationCss = { keyframesCss: string animationCss: string @@ -57,13 +61,13 @@ function validFrameDurations( frameDurationsMs: number[] | undefined, frames: number ): number[] | null { - // Why: Array.isArray, not a truthiness check — persisted/RPC-synced sprites - // are untrusted, so a corrupt non-array value (e.g. { length: 6 }) must fail - // here rather than throw on .length/.every during render. + // Why: Array.isArray + bounds, not a truthiness check — persisted/RPC-synced + // sprites are untrusted, so a corrupt non-array or out-of-range hold falls back + // to uniform pacing instead of throwing or freezing the overlay. if ( Array.isArray(frameDurationsMs) && frameDurationsMs.length === frames && - frameDurationsMs.every((ms) => Number.isFinite(ms) && ms > 0) + frameDurationsMs.every((ms) => Number.isFinite(ms) && ms > 0 && ms <= MAX_FRAME_DURATION_MS) ) { return frameDurationsMs } @@ -71,10 +75,8 @@ function validFrameDurations( } // Cumulative step-end stops, one per frame. Returns null (→ uniform fallback) -// when a frame is too short to survive our 4-decimal precision: either two -// stops collapse to the same percentage, or the final stop rounds to 100% and -// so has no interval before the loop. Either way the frame would vanish, so we -// degrade to uniform pacing rather than silently drop it. +// when a frame is too short to survive 4-decimal precision (two stops collapse, +// or the final stop rounds to 100%) so no frame is silently dropped. function stepEndStops( durations: number[], totalMs: number, diff --git a/src/renderer/src/components/pet/usePetPointerInteraction.ts b/src/renderer/src/components/pet/usePetPointerInteraction.ts index 9a3afd29d39..92d3a45c6c9 100644 --- a/src/renderer/src/components/pet/usePetPointerInteraction.ts +++ b/src/renderer/src/components/pet/usePetPointerInteraction.ts @@ -35,10 +35,8 @@ export function usePetPointerInteraction( // Why: horizontal baseline for the drag-direction hysteresis, advanced only on // an accepted direction. Kept separate from dragOffsetRef (position math). const dragBaselineXRef = useRef(0) - // Why: direction and the owning pointer are read+written inside pointer - // handlers, so keep them in refs immune to React's render batching. Reading - // the direction from state would let two coalesced moves in one commit - // resurrect a stale direction; `dragAnimation` state exists only to render. + // Why: read+written inside handlers, so keep in refs immune to render batching + // (state would let two coalesced moves resurrect a stale direction). const dragDirectionRef = useRef(null) const activePointerRef = useRef(null)